From 7078f357c441180a13f76beb4ef592e82d4fc806 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 12 May 2026 19:55:43 -0400 Subject: [PATCH 1/2] Add conversation session abilities --- agents-api.php | 2 + composer.json | 1 + .../class-wp-agent-conversation-sessions.php | 34 ++ .../class-wp-agent-conversation-store.php | 16 + ...-agents-conversation-session-abilities.php | 433 ++++++++++++++++++ ...s-conversation-session-abilities-smoke.php | 242 ++++++++++ 6 files changed, 728 insertions(+) create mode 100644 src/Transcripts/class-wp-agent-conversation-sessions.php create mode 100644 src/Transcripts/register-agents-conversation-session-abilities.php create mode 100644 tests/agents-conversation-session-abilities-smoke.php diff --git a/agents-api.php b/agents-api.php index 715e428..1ae5bde 100644 --- a/agents-api.php +++ b/agents-api.php @@ -58,6 +58,7 @@ require_once AGENTS_API_PATH . 'src/Identity/class-wp-agent-materialized-identity.php'; require_once AGENTS_API_PATH . 'src/Identity/class-wp-agent-identity-store.php'; require_once AGENTS_API_PATH . 'src/Transcripts/class-wp-agent-conversation-store.php'; +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/Approvals/class-wp-agent-pending-action-store.php'; @@ -72,6 +73,7 @@ require_once AGENTS_API_PATH . 'src/Consent/class-wp-agent-default-consent-policy.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-message.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-execution-principal.php'; +require_once AGENTS_API_PATH . 'src/Transcripts/register-agents-conversation-session-abilities.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-effective-agent-resolver.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-compaction-item.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-compaction-conservation.php'; diff --git a/composer.json b/composer.json index e3827ce..51912b9 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "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/action-policy-values-smoke.php", "php tests/consent-policy-smoke.php", "php tests/tool-policy-contracts-smoke.php", diff --git a/src/Transcripts/class-wp-agent-conversation-sessions.php b/src/Transcripts/class-wp-agent-conversation-sessions.php new file mode 100644 index 0000000..af7d722 --- /dev/null +++ b/src/Transcripts/class-wp-agent-conversation-sessions.php @@ -0,0 +1,34 @@ + $context Host-owned request context. + * @return WP_Agent_Conversation_Store|null + */ + public static function get_store( array $context = array() ): ?WP_Agent_Conversation_Store { + if ( isset( $context['conversation_store'] ) && $context['conversation_store'] instanceof WP_Agent_Conversation_Store ) { + return $context['conversation_store']; + } + + $store = function_exists( 'apply_filters' ) ? apply_filters( 'wp_agent_conversation_store', null, $context ) : null; + return $store instanceof WP_Agent_Conversation_Store ? $store : null; + } +} diff --git a/src/Transcripts/class-wp-agent-conversation-store.php b/src/Transcripts/class-wp-agent-conversation-store.php index 3de919a..c442d82 100644 --- a/src/Transcripts/class-wp-agent-conversation-store.php +++ b/src/Transcripts/class-wp-agent-conversation-store.php @@ -36,6 +36,22 @@ interface WP_Agent_Conversation_Store { */ public function create_session( WP_Agent_Workspace_Scope $workspace, int $user_id, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ): string; + /** + * List transcript sessions for one workspace/user pair. + * + * Implementations should return newest sessions first by default and honor the + * `include_messages` arg. List callers pass `include_messages => false` by + * default so concrete stores can avoid loading full transcript payloads. + * Common filter/pagination keys are `limit`, `offset`, `agent_slug`, and + * `context`. + * + * @param WP_Agent_Workspace_Scope $workspace Workspace owning the sessions. + * @param int $user_id WordPress user ID owning the sessions. + * @param array $args Optional host-supported filters/pagination. + * @return array> Session rows. + */ + public function list_sessions( WP_Agent_Workspace_Scope $workspace, int $user_id, array $args = array() ): array; + /** * Retrieve a transcript session by ID. * diff --git a/src/Transcripts/register-agents-conversation-session-abilities.php b/src/Transcripts/register-agents-conversation-session-abilities.php new file mode 100644 index 0000000..1b5dc25 --- /dev/null +++ b/src/Transcripts/register-agents-conversation-session-abilities.php @@ -0,0 +1,433 @@ + 'Agents API', + 'description' => 'Cross-cutting abilities provided by the Agents API substrate.', + ) + ); + } +); + +add_action( + 'wp_abilities_api_init', + static function (): void { + $abilities = array( + AGENTS_LIST_CONVERSATION_SESSIONS_ABILITY => array( + 'label' => 'List Conversation Sessions', + 'description' => 'List conversation sessions for the current principal in a workspace.', + 'input_schema' => agents_conversation_sessions_list_input_schema(), + 'output_schema' => agents_conversation_sessions_list_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_list_conversation_sessions', + 'annotations' => array( 'idempotent' => true ), + ), + AGENTS_GET_CONVERSATION_SESSION_ABILITY => array( + 'label' => 'Get Conversation Session', + 'description' => 'Read one conversation session owned by the current principal.', + 'input_schema' => agents_conversation_session_id_input_schema(), + 'output_schema' => agents_conversation_session_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_get_conversation_session', + 'annotations' => array( 'idempotent' => true ), + ), + AGENTS_CREATE_CONVERSATION_SESSION_ABILITY => array( + 'label' => 'Create Conversation Session', + 'description' => 'Create an empty conversation session for the current principal in a workspace.', + 'input_schema' => agents_conversation_sessions_create_input_schema(), + 'output_schema' => agents_conversation_session_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_create_conversation_session', + 'annotations' => array( + 'destructive' => true, + 'idempotent' => false, + ), + ), + AGENTS_UPDATE_CONVERSATION_SESSION_TITLE_ABILITY => array( + 'label' => 'Update Conversation Session Title', + 'description' => 'Update the stored display title for a conversation session owned by the current principal.', + 'input_schema' => agents_conversation_sessions_update_title_input_schema(), + 'output_schema' => agents_conversation_session_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_update_conversation_session_title', + 'annotations' => array( + 'destructive' => true, + 'idempotent' => false, + ), + ), + AGENTS_DELETE_CONVERSATION_SESSION_ABILITY => array( + 'label' => 'Delete Conversation Session', + 'description' => 'Delete a conversation session owned by the current principal.', + 'input_schema' => agents_conversation_session_id_input_schema(), + 'output_schema' => array( + 'type' => 'object', + 'required' => array( 'deleted' ), + 'properties' => array( 'deleted' => array( 'type' => 'boolean' ) ), + ), + 'execute_callback' => __NAMESPACE__ . '\\agents_delete_conversation_session', + 'annotations' => array( + 'destructive' => true, + 'idempotent' => true, + ), + ), + ); + + foreach ( $abilities as $ability => $args ) { + if ( wp_has_ability( $ability ) ) { + continue; + } + + wp_register_ability( + $ability, + array( + 'label' => $args['label'], + 'description' => $args['description'], + 'category' => 'agents-api', + 'input_schema' => $args['input_schema'], + 'output_schema' => $args['output_schema'], + 'execute_callback' => $args['execute_callback'], + 'permission_callback' => __NAMESPACE__ . '\\agents_conversation_sessions_permission', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => $args['annotations'], + ), + ) + ); + } + } +); + +/** @return array|\WP_Error */ +function agents_list_conversation_sessions( array $input ) { + $context = agents_conversation_sessions_context( $input ); + if ( is_wp_error( $context ) ) { + return $context; + } + + $workspace = agents_conversation_sessions_workspace( $input ); + if ( is_wp_error( $workspace ) ) { + return $workspace; + } + + $args = array( + 'limit' => 50, + 'offset' => 0, + 'include_messages' => false, + ); + if ( isset( $input['limit'] ) ) { + $args['limit'] = max( 1, min( 100, (int) $input['limit'] ) ); + } + if ( isset( $input['offset'] ) ) { + $args['offset'] = max( 0, (int) $input['offset'] ); + } + if ( isset( $input['agent'] ) ) { + $args['agent_slug'] = (string) $input['agent']; + } + if ( isset( $input['context'] ) ) { + $args['context'] = (string) $input['context']; + } + + $sessions = $context['store']->list_sessions( $workspace, $context['principal']->acting_user_id, $args ); + + return array( + 'sessions' => array_map( __NAMESPACE__ . '\\agents_conversation_session_summary', $sessions ), + ); +} + +/** @return array|\WP_Error */ +function agents_get_conversation_session( array $input ) { + $context = agents_conversation_sessions_context( $input ); + if ( is_wp_error( $context ) ) { + return $context; + } + + $session = agents_conversation_sessions_owned_session( (string) ( $input['session_id'] ?? '' ), $context ); + if ( is_wp_error( $session ) ) { + return $session; + } + + return array( 'session' => agents_conversation_session_full( $session ) ); +} + +/** @return array|\WP_Error */ +function agents_create_conversation_session( array $input ) { + $context = agents_conversation_sessions_context( $input ); + if ( is_wp_error( $context ) ) { + return $context; + } + + $workspace = agents_conversation_sessions_workspace( $input ); + if ( is_wp_error( $workspace ) ) { + return $workspace; + } + + $metadata = isset( $input['metadata'] ) && is_array( $input['metadata'] ) ? $input['metadata'] : array(); + $agent_slug = isset( $input['agent'] ) ? (string) $input['agent'] : $context['principal']->effective_agent_id; + $mode = isset( $input['context'] ) ? (string) $input['context'] : WP_Agent_Execution_Principal::REQUEST_CONTEXT_CHAT; + $session_id = $context['store']->create_session( $workspace, $context['principal']->acting_user_id, $agent_slug, $metadata, $mode ); + + if ( '' === $session_id ) { + return new \WP_Error( 'agents_conversation_session_create_failed', 'The conversation session store did not create a session.' ); + } + + $session = $context['store']->get_session( $session_id ); + return array( 'session' => agents_conversation_session_full( is_array( $session ) ? $session : array( 'session_id' => $session_id ) ) ); +} + +/** @return array|\WP_Error */ +function agents_update_conversation_session_title( array $input ) { + $context = agents_conversation_sessions_context( $input ); + if ( is_wp_error( $context ) ) { + return $context; + } + + $session = agents_conversation_sessions_owned_session( (string) ( $input['session_id'] ?? '' ), $context ); + if ( is_wp_error( $session ) ) { + return $session; + } + + $title = trim( (string) ( $input['title'] ?? '' ) ); + if ( '' === $title ) { + return new \WP_Error( 'agents_conversation_session_invalid_title', 'Conversation session title must be a non-empty string.' ); + } + + if ( ! $context['store']->update_title( $session['session_id'], $title ) ) { + return new \WP_Error( 'agents_conversation_session_update_failed', 'The conversation session store did not update the title.' ); + } + + $session['title'] = $title; + + return array( 'session' => agents_conversation_session_full( $session ) ); +} + +/** @return array|\WP_Error */ +function agents_delete_conversation_session( array $input ) { + $context = agents_conversation_sessions_context( $input ); + if ( is_wp_error( $context ) ) { + return $context; + } + + $session = agents_conversation_sessions_owned_session( (string) ( $input['session_id'] ?? '' ), $context ); + if ( is_wp_error( $session ) ) { + return $session; + } + + if ( ! $context['store']->delete_session( $session['session_id'] ) ) { + return new \WP_Error( 'agents_conversation_session_delete_failed', 'The conversation session store did not delete the session.' ); + } + + return array( 'deleted' => true ); +} + +function agents_conversation_sessions_permission( array $input ): bool { + $allowed = function_exists( 'current_user_can' ) ? current_user_can( 'read' ) : false; + return (bool) apply_filters( 'agents_conversation_sessions_permission', $allowed, $input ); +} + +/** @return array{store:WP_Agent_Conversation_Store,principal:WP_Agent_Execution_Principal}|\WP_Error */ +function agents_conversation_sessions_context( array $input ) { + $principal = agents_conversation_sessions_principal( $input ); + if ( ! $principal instanceof WP_Agent_Execution_Principal ) { + return new \WP_Error( 'agents_conversation_session_unauthenticated', 'A conversation session principal could not be resolved.' ); + } + + $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 WP_Agent_Conversation_Store is registered. Provide one with the wp_agent_conversation_store filter.' ); + } + + return array( + 'store' => $store, + 'principal' => $principal, + ); +} + +function agents_conversation_sessions_principal( array $input ): ?WP_Agent_Execution_Principal { + if ( isset( $input['principal'] ) && $input['principal'] instanceof WP_Agent_Execution_Principal ) { + return $input['principal']; + } + + if ( isset( $input['principal'] ) && is_array( $input['principal'] ) ) { + return WP_Agent_Execution_Principal::from_array( $input['principal'] ); + } + + $principal = WP_Agent_Execution_Principal::resolve( array( 'request_context' => WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST ) + $input ); + if ( $principal instanceof WP_Agent_Execution_Principal ) { + return $principal; + } + + $user_id = function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0; + if ( $user_id <= 0 ) { + return null; + } + + return WP_Agent_Execution_Principal::user_session( + $user_id, + isset( $input['agent'] ) ? (string) $input['agent'] : '__wordpress_user__', + WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, + array(), + agents_conversation_sessions_workspace_key( $input ) + ); +} + +/** @return WP_Agent_Workspace_Scope|\WP_Error */ +function agents_conversation_sessions_workspace( array $input ) { + try { + if ( isset( $input['workspace'] ) && is_array( $input['workspace'] ) ) { + return WP_Agent_Workspace_Scope::from_array( $input['workspace'] ); + } + + return WP_Agent_Workspace_Scope::from_parts( + (string) ( $input['workspace_type'] ?? 'site' ), + (string) ( $input['workspace_id'] ?? agents_conversation_sessions_default_workspace_id() ) + ); + } catch ( \InvalidArgumentException $exception ) { + return new \WP_Error( 'agents_conversation_session_invalid_workspace', $exception->getMessage() ); + } +} + +function agents_conversation_sessions_workspace_key( array $input ): ?string { + $workspace = agents_conversation_sessions_workspace( $input ); + return $workspace instanceof WP_Agent_Workspace_Scope ? $workspace->key() : null; +} + +function agents_conversation_sessions_default_workspace_id(): string { + if ( function_exists( 'get_current_blog_id' ) ) { + return (string) get_current_blog_id(); + } + + return 'default'; +} + +/** @return array|\WP_Error */ +function agents_conversation_sessions_owned_session( string $session_id, array $context ) { + if ( '' === trim( $session_id ) ) { + return new \WP_Error( 'agents_conversation_session_invalid_id', 'Conversation session ID must be a non-empty string.' ); + } + + $session = $context['store']->get_session( $session_id ); + if ( ! is_array( $session ) ) { + return new \WP_Error( 'agents_conversation_session_not_found', 'Conversation session not found.' ); + } + + $principal = $context['principal']; + if ( (int) ( $session['user_id'] ?? 0 ) !== $principal->acting_user_id && ! agents_conversation_sessions_can_manage_any() ) { + return new \WP_Error( 'agents_conversation_session_forbidden', 'The current principal cannot access this conversation session.' ); + } + + return $session; +} + +function agents_conversation_sessions_can_manage_any(): bool { + return function_exists( 'current_user_can' ) && current_user_can( 'manage_options' ); +} + +/** @return array */ +function agents_conversation_session_summary( array $session ): array { + unset( $session['messages'] ); + return $session; +} + +/** @return array */ +function agents_conversation_session_full( array $session ): array { + if ( ! isset( $session['messages'] ) || ! is_array( $session['messages'] ) ) { + $session['messages'] = array(); + } + + if ( ! isset( $session['metadata'] ) || ! is_array( $session['metadata'] ) ) { + $session['metadata'] = array(); + } + + return $session; +} + +function agents_conversation_sessions_workspace_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'workspace_type', 'workspace_id' ), + 'properties' => array( + 'workspace_type' => array( 'type' => 'string' ), + 'workspace_id' => array( 'type' => 'string' ), + ), + ); +} + +function agents_conversation_sessions_list_input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'workspace' => agents_conversation_sessions_workspace_schema(), + 'limit' => array( 'type' => 'integer' ), + 'offset' => array( 'type' => 'integer' ), + 'agent' => array( 'type' => 'string' ), + 'context' => array( 'type' => 'string' ), + ), + ); +} + +function agents_conversation_sessions_create_input_schema(): array { + $schema = agents_conversation_sessions_list_input_schema(); + $schema['properties']['metadata'] = array( 'type' => 'object' ); + return $schema; +} + +function agents_conversation_session_id_input_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'session_id' ), + 'properties' => array( 'session_id' => array( 'type' => 'string' ) ), + ); +} + +function agents_conversation_sessions_update_title_input_schema(): array { + $schema = agents_conversation_session_id_input_schema(); + $schema['required'][] = 'title'; + $schema['properties']['title'] = array( 'type' => 'string' ); + return $schema; +} + +function agents_conversation_sessions_list_output_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'sessions' ), + 'properties' => array( + 'sessions' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + ), + ); +} + +function agents_conversation_session_output_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'session' ), + 'properties' => array( 'session' => array( 'type' => 'object' ) ), + ); +} diff --git a/tests/agents-conversation-session-abilities-smoke.php b/tests/agents-conversation-session-abilities-smoke.php new file mode 100644 index 0000000..e72f790 --- /dev/null +++ b/tests/agents-conversation-session-abilities-smoke.php @@ -0,0 +1,242 @@ +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 { + return in_array( $cap, $GLOBALS['__smoke_caps'] ?? array(), true ); +} + +function get_current_user_id(): int { + return (int) ( $GLOBALS['__smoke_current_user_id'] ?? 0 ); +} + +function get_current_blog_id(): int { + return 42; +} + +$GLOBALS['__smoke_filters'] = array(); +$GLOBALS['__smoke_abilities'] = array(); +$GLOBALS['__smoke_categories'] = 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 wp_has_ability_category( string $category ): bool { + return isset( $GLOBALS['__smoke_categories'][ $category ] ); +} + +function wp_register_ability_category( string $category, array $args ): void { + $GLOBALS['__smoke_categories'][ $category ] = $args; +} + +function wp_has_ability( string $ability ): bool { + return isset( $GLOBALS['__smoke_abilities'][ $ability ] ); +} + +function wp_register_ability( string $ability, array $args ): void { + $GLOBALS['__smoke_abilities'][ $ability ] = $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/Workspace/class-wp-agent-workspace-scope.php'; +require_once __DIR__ . '/../src/Runtime/class-wp-agent-execution-principal.php'; +require_once __DIR__ . '/../src/Transcripts/class-wp-agent-conversation-store.php'; +require_once __DIR__ . '/../src/Transcripts/class-wp-agent-conversation-sessions.php'; +require_once __DIR__ . '/../src/Transcripts/register-agents-conversation-session-abilities.php'; + +use AgentsAPI\AI\WP_Agent_Execution_Principal; +use AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Store; +use AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope; +use function AgentsAPI\Core\Database\Chat\agents_create_conversation_session; +use function AgentsAPI\Core\Database\Chat\agents_delete_conversation_session; +use function AgentsAPI\Core\Database\Chat\agents_get_conversation_session; +use function AgentsAPI\Core\Database\Chat\agents_list_conversation_sessions; +use function AgentsAPI\Core\Database\Chat\agents_update_conversation_session_title; +use const AgentsAPI\Core\Database\Chat\AGENTS_CREATE_CONVERSATION_SESSION_ABILITY; +use const AgentsAPI\Core\Database\Chat\AGENTS_DELETE_CONVERSATION_SESSION_ABILITY; +use const AgentsAPI\Core\Database\Chat\AGENTS_GET_CONVERSATION_SESSION_ABILITY; +use const AgentsAPI\Core\Database\Chat\AGENTS_LIST_CONVERSATION_SESSIONS_ABILITY; +use const AgentsAPI\Core\Database\Chat\AGENTS_UPDATE_CONVERSATION_SESSION_TITLE_ABILITY; + +$store = new class() implements WP_Agent_Conversation_Store { + /** @var array> */ + public array $sessions = array(); + public array $last_list_args = array(); + + public function create_session( WP_Agent_Workspace_Scope $workspace, int $user_id, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ): string { + $session_id = 's-' . ( count( $this->sessions ) + 1 ); + $this->sessions[ $session_id ] = array( + 'session_id' => $session_id, + 'workspace_type' => $workspace->workspace_type, + 'workspace_id' => $workspace->workspace_id, + 'user_id' => $user_id, + 'agent_slug' => $agent_slug, + 'title' => '', + 'messages' => array(), + 'metadata' => $metadata, + 'context' => $context, + 'created_at' => '2026-05-12T00:00:00Z', + 'updated_at' => '2026-05-12T00:00:00Z', + ); + return $session_id; + } + + public function list_sessions( WP_Agent_Workspace_Scope $workspace, int $user_id, array $args = array() ): array { + $this->last_list_args = $args; + return array_values( + array_filter( + $this->sessions, + static fn( array $session ): bool => $session['workspace_type'] === $workspace->workspace_type && $session['workspace_id'] === $workspace->workspace_id && $session['user_id'] === $user_id + ) + ); + } + + public function get_session( string $session_id ): ?array { + return $this->sessions[ $session_id ] ?? null; + } + + public function update_session( string $session_id, array $messages, array $metadata = array(), string $provider = '', string $model = '', ?string $provider_response_id = null ): bool { + unset( $provider, $model, $provider_response_id ); + if ( ! isset( $this->sessions[ $session_id ] ) ) { + return false; + } + $this->sessions[ $session_id ]['messages'] = $messages; + $this->sessions[ $session_id ]['metadata'] = $metadata; + return true; + } + + public function delete_session( string $session_id ): bool { + unset( $this->sessions[ $session_id ] ); + return true; + } + + 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 { + unset( $workspace, $user_id, $seconds, $context, $token_id ); + return null; + } + + public function update_title( string $session_id, string $title ): bool { + if ( ! isset( $this->sessions[ $session_id ] ) ) { + return false; + } + $this->sessions[ $session_id ]['title'] = $title; + return true; + } +}; + +add_filter( 'wp_agent_conversation_store', static fn() => $store ); + +$principal = WP_Agent_Execution_Principal::user_session( 7, 'demo-agent', WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST ); +$input = array( + 'principal' => $principal, + 'workspace' => array( + 'workspace_type' => 'site', + 'workspace_id' => '42', + ), + 'agent' => 'demo-agent', + 'metadata' => array( 'client' => 'frontend' ), + 'context' => 'chat', +); + +$created = agents_create_conversation_session( $input ); +smoke_assert( 's-1', $created['session']['session_id'] ?? null, 'create returns stored session', $failures, $passes ); +smoke_assert( 'frontend', $created['session']['metadata']['client'] ?? null, 'create passes metadata to store', $failures, $passes ); + +$store->update_session( 's-1', array( array( 'role' => 'user', 'content' => 'hello' ) ) ); +$got = agents_get_conversation_session( array( 'principal' => $principal, 'session_id' => 's-1' ) ); +smoke_assert( 'hello', $got['session']['messages'][0]['content'] ?? null, 'get returns messages', $failures, $passes ); + +$listed = agents_list_conversation_sessions( $input + array( 'limit' => 10 ) ); +smoke_assert( 's-1', $listed['sessions'][0]['session_id'] ?? null, 'list returns owned workspace session', $failures, $passes ); +smoke_assert( false, isset( $listed['sessions'][0]['messages'] ), 'list omits transcript messages', $failures, $passes ); +smoke_assert( 10, $store->last_list_args['limit'] ?? null, 'list forwards pagination args', $failures, $passes ); +smoke_assert( false, $store->last_list_args['include_messages'] ?? true, 'list requests summary rows by default', $failures, $passes ); + +$renamed = agents_update_conversation_session_title( array( 'principal' => $principal, 'session_id' => 's-1', 'title' => 'New title' ) ); +smoke_assert( 'New title', $renamed['session']['title'] ?? null, 'update title delegates to store', $failures, $passes ); + +$other_principal = WP_Agent_Execution_Principal::user_session( 8, 'demo-agent', WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST ); +$forbidden = agents_get_conversation_session( array( 'principal' => $other_principal, 'session_id' => 's-1' ) ); +smoke_assert( true, $forbidden instanceof WP_Error, 'get blocks sessions owned by another user', $failures, $passes ); +smoke_assert( 'agents_conversation_session_forbidden', $forbidden instanceof WP_Error ? $forbidden->get_error_code() : '', 'forbidden error code', $failures, $passes ); + +$deleted = agents_delete_conversation_session( array( 'principal' => $principal, 'session_id' => 's-1' ) ); +smoke_assert( true, $deleted['deleted'] ?? false, 'delete delegates to store', $failures, $passes ); +smoke_assert( null, $store->get_session( 's-1' ), 'delete removes session', $failures, $passes ); + +do_action( 'wp_abilities_api_categories_init' ); +do_action( 'wp_abilities_api_init' ); + +smoke_assert( true, wp_has_ability( AGENTS_LIST_CONVERSATION_SESSIONS_ABILITY ), 'list ability registers', $failures, $passes ); +smoke_assert( true, wp_has_ability( AGENTS_GET_CONVERSATION_SESSION_ABILITY ), 'get ability registers', $failures, $passes ); +smoke_assert( true, wp_has_ability( AGENTS_CREATE_CONVERSATION_SESSION_ABILITY ), 'create ability registers', $failures, $passes ); +smoke_assert( true, wp_has_ability( AGENTS_UPDATE_CONVERSATION_SESSION_TITLE_ABILITY ), 'update-title ability registers', $failures, $passes ); +smoke_assert( true, wp_has_ability( AGENTS_DELETE_CONVERSATION_SESSION_ABILITY ), 'delete ability registers', $failures, $passes ); + +if ( $failures ) { + echo "\nFAILED: " . count( $failures ) . " agents conversation session ability assertions failed.\n"; + exit( 1 ); +} + +echo "\nAll {$passes} agents conversation session ability assertions passed.\n"; From 5d729d37c3c5f200640f62a0ff13213a1e9cb003 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 13 May 2026 07:50:29 -0400 Subject: [PATCH 2/2] Fix conversation session ability lint --- ...-agents-conversation-session-abilities.php | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Transcripts/register-agents-conversation-session-abilities.php b/src/Transcripts/register-agents-conversation-session-abilities.php index 1b5dc25..b1cef54 100644 --- a/src/Transcripts/register-agents-conversation-session-abilities.php +++ b/src/Transcripts/register-agents-conversation-session-abilities.php @@ -15,11 +15,11 @@ defined( 'ABSPATH' ) || exit; -const AGENTS_LIST_CONVERSATION_SESSIONS_ABILITY = 'agents/list-conversation-sessions'; -const AGENTS_GET_CONVERSATION_SESSION_ABILITY = 'agents/get-conversation-session'; -const AGENTS_CREATE_CONVERSATION_SESSION_ABILITY = 'agents/create-conversation-session'; -const AGENTS_UPDATE_CONVERSATION_SESSION_TITLE_ABILITY = 'agents/update-conversation-session-title'; -const AGENTS_DELETE_CONVERSATION_SESSION_ABILITY = 'agents/delete-conversation-session'; +const AGENTS_LIST_CONVERSATION_SESSIONS_ABILITY = 'agents/list-conversation-sessions'; +const AGENTS_GET_CONVERSATION_SESSION_ABILITY = 'agents/get-conversation-session'; +const AGENTS_CREATE_CONVERSATION_SESSION_ABILITY = 'agents/create-conversation-session'; +const AGENTS_UPDATE_CONVERSATION_SESSION_TITLE_ABILITY = 'agents/update-conversation-session-title'; +const AGENTS_DELETE_CONVERSATION_SESSION_ABILITY = 'agents/delete-conversation-session'; add_action( 'wp_abilities_api_categories_init', @@ -42,7 +42,7 @@ static function (): void { 'wp_abilities_api_init', static function (): void { $abilities = array( - AGENTS_LIST_CONVERSATION_SESSIONS_ABILITY => array( + AGENTS_LIST_CONVERSATION_SESSIONS_ABILITY => array( 'label' => 'List Conversation Sessions', 'description' => 'List conversation sessions for the current principal in a workspace.', 'input_schema' => agents_conversation_sessions_list_input_schema(), @@ -50,7 +50,7 @@ static function (): void { 'execute_callback' => __NAMESPACE__ . '\\agents_list_conversation_sessions', 'annotations' => array( 'idempotent' => true ), ), - AGENTS_GET_CONVERSATION_SESSION_ABILITY => array( + AGENTS_GET_CONVERSATION_SESSION_ABILITY => array( 'label' => 'Get Conversation Session', 'description' => 'Read one conversation session owned by the current principal.', 'input_schema' => agents_conversation_session_id_input_schema(), @@ -58,7 +58,7 @@ static function (): void { 'execute_callback' => __NAMESPACE__ . '\\agents_get_conversation_session', 'annotations' => array( 'idempotent' => true ), ), - AGENTS_CREATE_CONVERSATION_SESSION_ABILITY => array( + AGENTS_CREATE_CONVERSATION_SESSION_ABILITY => array( 'label' => 'Create Conversation Session', 'description' => 'Create an empty conversation session for the current principal in a workspace.', 'input_schema' => agents_conversation_sessions_create_input_schema(), @@ -80,7 +80,7 @@ static function (): void { 'idempotent' => false, ), ), - AGENTS_DELETE_CONVERSATION_SESSION_ABILITY => array( + AGENTS_DELETE_CONVERSATION_SESSION_ABILITY => array( 'label' => 'Delete Conversation Session', 'description' => 'Delete a conversation session owned by the current principal.', 'input_schema' => agents_conversation_session_id_input_schema(), @@ -405,9 +405,9 @@ function agents_conversation_session_id_input_schema(): array { } function agents_conversation_sessions_update_title_input_schema(): array { - $schema = agents_conversation_session_id_input_schema(); - $schema['required'][] = 'title'; - $schema['properties']['title'] = array( 'type' => 'string' ); + $schema = agents_conversation_session_id_input_schema(); + $schema['required'][] = 'title'; + $schema['properties']['title'] = array( 'type' => 'string' ); return $schema; }