diff --git a/agents-api.php b/agents-api.php index 7d13c38..77c95ff 100644 --- a/agents-api.php +++ b/agents-api.php @@ -60,6 +60,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-principal-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'; diff --git a/src/Runtime/class-wp-agent-execution-principal.php b/src/Runtime/class-wp-agent-execution-principal.php index 34a1487..84db875 100644 --- a/src/Runtime/class-wp-agent-execution-principal.php +++ b/src/Runtime/class-wp-agent-execution-principal.php @@ -25,6 +25,11 @@ final class WP_Agent_Execution_Principal { public const AUTH_SOURCE_AUDIENCE = 'audience'; public const AUTH_SOURCE_SYSTEM = 'system'; + public const OWNER_TYPE_USER = 'user'; + public const OWNER_TYPE_AUDIENCE = 'audience'; + public const OWNER_TYPE_TOKEN = 'token'; + public const OWNER_TYPE_SYSTEM = 'system'; + public const REQUEST_CONTEXT_REST = 'rest'; public const REQUEST_CONTEXT_CLI = 'cli'; public const REQUEST_CONTEXT_CRON = 'cron'; @@ -43,6 +48,8 @@ final class WP_Agent_Execution_Principal { * @param \WP_Agent_Caller_Context|null $caller_context Optional cross-site caller context claims. * @param string|null $audience_id Optional non-user audience/principal identifier. * @param array $audience_claims Optional host-owned audience claims. + * @param string|null $owner_type Optional canonical transcript owner type. + * @param string|null $owner_key Optional opaque transcript owner key scoped to the owner type. */ public function __construct( public readonly int $acting_user_id, @@ -57,6 +64,8 @@ public function __construct( public readonly ?\WP_Agent_Caller_Context $caller_context = null, public readonly ?string $audience_id = null, public readonly array $audience_claims = array(), + public readonly ?string $owner_type = null, + public readonly ?string $owner_key = null, ) { if ( $this->acting_user_id < 0 ) { throw self::invalid( 'acting_user_id', 'must be zero or a positive integer' ); @@ -89,6 +98,18 @@ public function __construct( if ( false === self::jsonEncode( $this->audience_claims ) ) { throw self::invalid( 'audience_claims', 'must be JSON serializable' ); } + + if ( ( null === $this->owner_type ) !== ( null === $this->owner_key ) ) { + throw self::invalid( 'owner', 'type and key must both be present or both be null' ); + } + + if ( null !== $this->owner_type && '' === trim( $this->owner_type ) ) { + throw self::invalid( 'owner_type', 'must be null or a non-empty string' ); + } + + if ( null !== $this->owner_key && '' === trim( $this->owner_key ) ) { + throw self::invalid( 'owner_key', 'must be null or a non-empty string' ); + } } /** @@ -158,8 +179,8 @@ public static function agent_token( int $acting_user_id, string $effective_agent * @param array $audience_claims Host-owned audience claims. * @return self */ - public static function audience( string $audience_id, string $effective_agent_id, string $request_context = self::REQUEST_CONTEXT_REST, array $request_metadata = array(), ?string $workspace_id = null, ?string $client_id = null, array $audience_claims = array() ): self { - return new self( 0, $effective_agent_id, self::AUTH_SOURCE_AUDIENCE, $request_context, null, $request_metadata, $workspace_id, $client_id, null, null, $audience_id, $audience_claims ); + public static function audience( string $audience_id, string $effective_agent_id, string $request_context = self::REQUEST_CONTEXT_REST, array $request_metadata = array(), ?string $workspace_id = null, ?string $client_id = null, array $audience_claims = array(), ?string $owner_key = null ): self { + return new self( 0, $effective_agent_id, self::AUTH_SOURCE_AUDIENCE, $request_context, null, $request_metadata, $workspace_id, $client_id, null, null, $audience_id, $audience_claims, null !== $owner_key ? self::OWNER_TYPE_AUDIENCE : null, $owner_key ); } /** @@ -199,7 +220,9 @@ public static function from_array( array $principal ): self { $capability_ceiling, $caller_context, array_key_exists( 'audience_id', $principal ) && null !== $principal['audience_id'] ? (string) $principal['audience_id'] : null, - isset( $principal['audience_claims'] ) && is_array( $principal['audience_claims'] ) ? $principal['audience_claims'] : array() + isset( $principal['audience_claims'] ) && is_array( $principal['audience_claims'] ) ? $principal['audience_claims'] : array(), + array_key_exists( 'owner_type', $principal ) && null !== $principal['owner_type'] ? (string) $principal['owner_type'] : null, + array_key_exists( 'owner_key', $principal ) && null !== $principal['owner_key'] ? (string) $principal['owner_key'] : null ); } @@ -222,9 +245,46 @@ public function to_array(): array { 'caller_context' => $this->caller_context instanceof \WP_Agent_Caller_Context ? $this->caller_context->to_array() : null, 'audience_id' => $this->audience_id, 'audience_claims' => $this->audience_claims, + 'owner_type' => $this->owner_type, + 'owner_key' => $this->owner_key, ); } + /** + * Return the canonical transcript owner for this principal. + * + * Runtime authorization and transcript ownership are intentionally separate. + * User principals can safely derive ownership from the WordPress user ID. Non-user + * principals must provide an opaque owner key resolved by the host, such as a + * browser-session key; audience access alone is not a transcript owner. + * + * @return array{type:string,key:string}|null Principal owner, or null when this principal is not transcript-ownable. + */ + public function conversation_owner(): ?array { + if ( null !== $this->owner_type && null !== $this->owner_key ) { + return array( + 'type' => $this->owner_type, + 'key' => $this->owner_key, + ); + } + + if ( $this->acting_user_id > 0 && self::AUTH_SOURCE_AGENT_TOKEN !== $this->auth_source ) { + return array( + 'type' => self::OWNER_TYPE_USER, + 'key' => (string) $this->acting_user_id, + ); + } + + if ( self::AUTH_SOURCE_AGENT_TOKEN === $this->auth_source && null !== $this->token_id ) { + return array( + 'type' => self::OWNER_TYPE_TOKEN, + 'key' => (string) $this->token_id, + ); + } + + return null; + } + /** * Whether this principal represents a host-resolved non-user audience. */ @@ -251,7 +311,9 @@ public function with_request_metadata( array $request_metadata ): self { $this->capability_ceiling, $this->caller_context, $this->audience_id, - $this->audience_claims + $this->audience_claims, + $this->owner_type, + $this->owner_key ); } diff --git a/src/Transcripts/class-wp-agent-principal-conversation-store.php b/src/Transcripts/class-wp-agent-principal-conversation-store.php new file mode 100644 index 0000000..4635e07 --- /dev/null +++ b/src/Transcripts/class-wp-agent-principal-conversation-store.php @@ -0,0 +1,60 @@ + 'user|audience|token|system', 'key' => '' )`. + * + * Legacy WP_Agent_Conversation_Store implementations remain valid for user-owned + * sessions through the int user ID methods above. + */ +interface WP_Agent_Principal_Conversation_Store extends WP_Agent_Conversation_Store { + + /** + * Create a new conversation transcript session for a canonical principal owner. + * + * @param WP_Agent_Workspace_Scope $workspace Workspace owning the session. + * @param array{type:string,key:string} $owner Canonical principal owner. + * @param string $agent_slug Registered agent slug, or empty string for agent-less sessions. + * @param array $metadata Arbitrary session metadata (JSON-serializable). + * @param string $context Execution mode ('chat', 'pipeline', 'system'). + * @return string Session ID (UUIDv4), or empty string on failure. + */ + public function create_session_for_owner( WP_Agent_Workspace_Scope $workspace, array $owner, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ): string; + + /** + * List transcript sessions for one workspace/principal-owner pair. + * + * @param WP_Agent_Workspace_Scope $workspace Workspace owning the sessions. + * @param array{type:string,key:string} $owner Canonical principal owner. + * @param array $args Optional host-supported filters/pagination. + * @return array> Session rows. + */ + public function list_sessions_for_owner( WP_Agent_Workspace_Scope $workspace, array $owner, array $args = array() ): array; + + /** + * Find a recent pending session for principal-owner scoped deduplication. + * + * @param WP_Agent_Workspace_Scope $workspace Workspace owning the session. + * @param array{type:string,key:string} $owner Canonical principal owner. + * @param int $seconds Lookback window. + * @param string $context Context filter. + * @param int|null $token_id Optional token ID for token-scoped dedup. + * @return array|null Session data or null if none. + */ + 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; +} diff --git a/src/Transcripts/register-agents-conversation-session-abilities.php b/src/Transcripts/register-agents-conversation-session-abilities.php index b1cef54..8beb19c 100644 --- a/src/Transcripts/register-agents-conversation-session-abilities.php +++ b/src/Transcripts/register-agents-conversation-session-abilities.php @@ -152,7 +152,10 @@ function agents_list_conversation_sessions( array $input ) { $args['context'] = (string) $input['context']; } - $sessions = $context['store']->list_sessions( $workspace, $context['principal']->acting_user_id, $args ); + $sessions = agents_conversation_sessions_list_for_owner( $context['store'], $workspace, $context['owner'], $args ); + if ( is_wp_error( $sessions ) ) { + return $sessions; + } return array( 'sessions' => array_map( __NAMESPACE__ . '\\agents_conversation_session_summary', $sessions ), @@ -189,7 +192,10 @@ function agents_create_conversation_session( array $input ) { $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 ); + $session_id = agents_conversation_sessions_create_for_owner( $context['store'], $workspace, $context['owner'], $agent_slug, $metadata, $mode ); + if ( is_wp_error( $session_id ) ) { + return $session_id; + } if ( '' === $session_id ) { return new \WP_Error( 'agents_conversation_session_create_failed', 'The conversation session store did not create a session.' ); @@ -249,24 +255,66 @@ function agents_conversation_sessions_permission( array $input ): bool { return (bool) apply_filters( 'agents_conversation_sessions_permission', $allowed, $input ); } -/** @return array{store:WP_Agent_Conversation_Store,principal:WP_Agent_Execution_Principal}|\WP_Error */ +/** @return array{store:WP_Agent_Conversation_Store,principal:WP_Agent_Execution_Principal,owner:array{type:string,key:string}}|\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.' ); } + $owner = $principal->conversation_owner(); + if ( null === $owner ) { + return new \WP_Error( 'agents_conversation_session_owner_required', 'The current principal does not provide a conversation session owner key.' ); + } + $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.' ); } + if ( ! $store instanceof WP_Agent_Principal_Conversation_Store && WP_Agent_Execution_Principal::OWNER_TYPE_USER !== $owner['type'] ) { + return new \WP_Error( 'agents_conversation_session_principal_store_required', 'The registered conversation session store does not support non-user principal owners.' ); + } + return array( 'store' => $store, 'principal' => $principal, + 'owner' => $owner, ); } +/** + * @param array{type:string,key:string} $owner Canonical principal owner. + * @return string|\WP_Error + */ +function agents_conversation_sessions_create_for_owner( WP_Agent_Conversation_Store $store, WP_Agent_Workspace_Scope $workspace, array $owner, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ) { + if ( $store instanceof WP_Agent_Principal_Conversation_Store ) { + return $store->create_session_for_owner( $workspace, $owner, $agent_slug, $metadata, $context ); + } + + if ( WP_Agent_Execution_Principal::OWNER_TYPE_USER !== $owner['type'] ) { + return new \WP_Error( 'agents_conversation_session_principal_store_required', 'The registered conversation session store does not support non-user principal owners.' ); + } + + return $store->create_session( $workspace, (int) $owner['key'], $agent_slug, $metadata, $context ); +} + +/** + * @param array{type:string,key:string} $owner Canonical principal owner. + * @return array>|\WP_Error + */ +function agents_conversation_sessions_list_for_owner( WP_Agent_Conversation_Store $store, WP_Agent_Workspace_Scope $workspace, array $owner, array $args = array() ) { + if ( $store instanceof WP_Agent_Principal_Conversation_Store ) { + return $store->list_sessions_for_owner( $workspace, $owner, $args ); + } + + if ( WP_Agent_Execution_Principal::OWNER_TYPE_USER !== $owner['type'] ) { + return new \WP_Error( 'agents_conversation_session_principal_store_required', 'The registered conversation session store does not support non-user principal owners.' ); + } + + return $store->list_sessions( $workspace, (int) $owner['key'], $args ); +} + 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']; @@ -335,14 +383,27 @@ function agents_conversation_sessions_owned_session( string $session_id, array $ 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() ) { + if ( ! agents_conversation_sessions_session_matches_owner( $session, $context['owner'] ) && ! agents_conversation_sessions_can_manage_any() ) { return new \WP_Error( 'agents_conversation_session_forbidden', 'The current principal cannot access this conversation session.' ); } return $session; } +/** + * @param array{type:string,key:string} $owner Canonical principal owner. + */ +function agents_conversation_sessions_session_matches_owner( array $session, array $owner ): bool { + $session_owner_type = $session['owner_type'] ?? $session['principal_owner_type'] ?? null; + $session_owner_key = $session['owner_key'] ?? $session['principal_owner_key'] ?? null; + + if ( null !== $session_owner_type || null !== $session_owner_key ) { + return (string) $session_owner_type === $owner['type'] && (string) $session_owner_key === $owner['key']; + } + + return WP_Agent_Execution_Principal::OWNER_TYPE_USER === $owner['type'] && (int) ( $session['user_id'] ?? 0 ) === (int) $owner['key']; +} + function agents_conversation_sessions_can_manage_any(): bool { return function_exists( 'current_user_can' ) && current_user_can( 'manage_options' ); } diff --git a/tests/agents-conversation-session-abilities-smoke.php b/tests/agents-conversation-session-abilities-smoke.php index e72f790..8833959 100644 --- a/tests/agents-conversation-session-abilities-smoke.php +++ b/tests/agents-conversation-session-abilities-smoke.php @@ -102,10 +102,12 @@ function smoke_assert( $expected, $actual, string $name, array &$failures, int & 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-principal-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_Principal_Conversation_Store; 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; @@ -221,10 +223,83 @@ public function update_title( string $session_id, string $title ): bool { 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 ); +$audience_without_owner = WP_Agent_Execution_Principal::audience( 'audience:public', 'demo-agent' ); +$owner_required = agents_list_conversation_sessions( array( 'principal' => $audience_without_owner ) ); +smoke_assert( 'agents_conversation_session_owner_required', $owner_required instanceof WP_Error ? $owner_required->get_error_code() : '', 'audience access alone cannot list sessions', $failures, $passes ); + +$audience_with_owner = WP_Agent_Execution_Principal::audience( 'audience:public', 'demo-agent', WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, array(), null, null, array(), 'browser:one' ); +$store_required = agents_list_conversation_sessions( array( 'principal' => $audience_with_owner ) ); +smoke_assert( 'agents_conversation_session_principal_store_required', $store_required instanceof WP_Error ? $store_required->get_error_code() : '', 'legacy store only accepts user owners', $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 ); +$principal_store = new class() implements WP_Agent_Principal_Conversation_Store { + /** @var array> */ + public array $sessions = array(); + + public function create_session_for_owner( WP_Agent_Workspace_Scope $workspace, array $owner, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ): string { + $session_id = 'p-' . ( count( $this->sessions ) + 1 ); + $this->sessions[ $session_id ] = array( + 'session_id' => $session_id, + 'workspace_type' => $workspace->workspace_type, + 'workspace_id' => $workspace->workspace_id, + 'owner_type' => $owner['type'], + 'owner_key' => $owner['key'], + 'user_id' => 0, + 'agent_slug' => $agent_slug, + 'title' => '', + 'messages' => array(), + 'metadata' => $metadata, + 'context' => $context, + ); + return $session_id; + } + + public function list_sessions_for_owner( WP_Agent_Workspace_Scope $workspace, array $owner, array $args = array() ): array { + unset( $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['owner_type'] === $owner['type'] && $session['owner_key'] === $owner['key'] + ) + ); + } + + 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 { + unset( $workspace, $owner, $seconds, $context, $token_id ); + return null; + } + + 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, array( 'type' => WP_Agent_Execution_Principal::OWNER_TYPE_USER, 'key' => (string) $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, array( 'type' => WP_Agent_Execution_Principal::OWNER_TYPE_USER, 'key' => (string) $user_id ), $args ); + } + + 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( $session_id, $messages, $metadata, $provider, $model, $provider_response_id ); 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 { $this->sessions[ $session_id ]['title'] = $title; return true; } +}; + +add_filter( 'wp_agent_conversation_store', static fn() => $principal_store, 20 ); + +$audience_created = agents_create_conversation_session( array( 'principal' => $audience_with_owner, 'workspace' => array( 'workspace_type' => 'site', 'workspace_id' => '42' ) ) ); +smoke_assert( 'p-1', $audience_created['session']['session_id'] ?? null, 'principal store creates audience-owned session', $failures, $passes ); +smoke_assert( 'browser:one', $principal_store->sessions['p-1']['owner_key'] ?? null, 'principal store receives opaque owner key', $failures, $passes ); + +$audience_listed = agents_list_conversation_sessions( array( 'principal' => $audience_with_owner, 'workspace' => array( 'workspace_type' => 'site', 'workspace_id' => '42' ) ) ); +smoke_assert( 'p-1', $audience_listed['sessions'][0]['session_id'] ?? null, 'principal store lists matching owner only', $failures, $passes ); + +$other_audience = WP_Agent_Execution_Principal::audience( 'audience:public', 'demo-agent', WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, array(), null, null, array(), 'browser:two' ); +$blocked_owner = agents_get_conversation_session( array( 'principal' => $other_audience, 'session_id' => 'p-1' ) ); +smoke_assert( 'agents_conversation_session_forbidden', $blocked_owner instanceof WP_Error ? $blocked_owner->get_error_code() : '', 'principal owner key blocks other audience sessions', $failures, $passes ); + do_action( 'wp_abilities_api_categories_init' ); do_action( 'wp_abilities_api_init' ); diff --git a/tests/execution-principal-smoke.php b/tests/execution-principal-smoke.php index b67ed09..a766d08 100644 --- a/tests/execution-principal-smoke.php +++ b/tests/execution-principal-smoke.php @@ -52,6 +52,7 @@ agents_api_smoke_assert_equals( 'site:42', $principal_array['workspace_id'], 'principal exports workspace id', $failures, $passes ); agents_api_smoke_assert_equals( 'kimaki', $principal_array['client_id'], 'principal exports client id', $failures, $passes ); agents_api_smoke_assert_equals( array( 'edit_posts' ), $principal_array['capability_ceiling']['allowed_capabilities'], 'principal exports capability ceiling', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'type' => AgentsAPI\AI\WP_Agent_Execution_Principal::OWNER_TYPE_TOKEN, 'key' => '456' ), $principal->conversation_owner(), 'agent token derives token conversation owner', $failures, $passes ); $from_array = AgentsAPI\AI\WP_Agent_Execution_Principal::from_array( array( @@ -96,6 +97,7 @@ agents_api_smoke_assert_equals( 'editor-agent', $user_session->effective_agent_id, 'user_session records effective agent id', $failures, $passes ); agents_api_smoke_assert_equals( AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_USER, $user_session->auth_source, 'user_session records user auth source', $failures, $passes ); agents_api_smoke_assert_equals( null, $user_session->token_id, 'user_session omits token id', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'type' => AgentsAPI\AI\WP_Agent_Execution_Principal::OWNER_TYPE_USER, 'key' => '99' ), $user_session->conversation_owner(), 'user_session derives user conversation owner', $failures, $passes ); add_filter( 'agents_api_execution_principal', @@ -143,6 +145,20 @@ static function ( $principal, array $context ) { agents_api_smoke_assert_equals( 'audience:docs-readers', $audience_principal->audience_id, 'audience principal records audience id', $failures, $passes ); agents_api_smoke_assert_equals( true, $audience_principal->has_audience(), 'audience principal reports audience presence', $failures, $passes ); agents_api_smoke_assert_equals( array( 'example' => 'docs-readers' ), $audience_principal->to_array()['audience_claims'], 'audience principal exports claims', $failures, $passes ); +agents_api_smoke_assert_equals( null, $audience_principal->conversation_owner(), 'audience access alone is not a conversation owner', $failures, $passes ); + +$audience_owner_principal = AgentsAPI\AI\WP_Agent_Execution_Principal::audience( + 'audience:docs-readers', + 'audience-gateway', + AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, + array( 'route' => '/agents/v1/chat' ), + 'site:42', + 'browser', + array( 'example' => 'docs-readers' ), + 'browser-session:opaque-123' +); +agents_api_smoke_assert_equals( array( 'type' => AgentsAPI\AI\WP_Agent_Execution_Principal::OWNER_TYPE_AUDIENCE, 'key' => 'browser-session:opaque-123' ), $audience_owner_principal->conversation_owner(), 'audience principal can carry opaque conversation owner key', $failures, $passes ); +agents_api_smoke_assert_equals( 'browser-session:opaque-123', $audience_owner_principal->to_array()['owner_key'], 'principal exports owner key', $failures, $passes ); $audience_from_array = AgentsAPI\AI\WP_Agent_Execution_Principal::from_array( array( @@ -152,10 +168,13 @@ static function ( $principal, array $context ) { 'request_context' => AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, 'audience_id' => 'audience:docs-readers', 'audience_claims' => array( 'tier' => 'viewer' ), + 'owner_type' => AgentsAPI\AI\WP_Agent_Execution_Principal::OWNER_TYPE_AUDIENCE, + 'owner_key' => 'browser-session:opaque-456', ) ); agents_api_smoke_assert_equals( 'audience:docs-readers', $audience_from_array->audience_id, 'from_array restores audience id', $failures, $passes ); agents_api_smoke_assert_equals( array( 'tier' => 'viewer' ), $audience_from_array->audience_claims, 'from_array restores audience claims', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'type' => AgentsAPI\AI\WP_Agent_Execution_Principal::OWNER_TYPE_AUDIENCE, 'key' => 'browser-session:opaque-456' ), $audience_from_array->conversation_owner(), 'from_array restores explicit conversation owner', $failures, $passes ); try { new AgentsAPI\AI\WP_Agent_Execution_Principal( -1, 'agent', AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_USER, AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST );