diff --git a/agents-api.php b/agents-api.php index 6707f18..445fd63 100644 --- a/agents-api.php +++ b/agents-api.php @@ -54,6 +54,8 @@ require_once AGENTS_API_PATH . 'src/Identity/MaterializedAgentIdentity.php'; require_once AGENTS_API_PATH . 'src/Identity/MaterializedAgentIdentityStoreInterface.php'; require_once AGENTS_API_PATH . 'src/Transcripts/ConversationTranscriptStoreInterface.php'; +require_once AGENTS_API_PATH . 'src/Transcripts/ConversationTranscriptLockInterface.php'; +require_once AGENTS_API_PATH . 'src/Transcripts/NullConversationTranscriptLock.php'; require_once AGENTS_API_PATH . 'src/Approvals/PendingActionStoreInterface.php'; require_once AGENTS_API_PATH . 'src/Approvals/PendingActionStatus.php'; require_once AGENTS_API_PATH . 'src/Approvals/PendingAction.php'; diff --git a/composer.json b/composer.json index 269dee7..ce92d80 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "php tests/workspace-scope-smoke.php", "php tests/compaction-item-smoke.php", "php tests/conversation-runner-contracts-smoke.php", + "php tests/conversation-transcript-lock-smoke.php", "php tests/conversation-compaction-smoke.php", "php tests/markdown-section-compaction-smoke.php", "php tests/context-registry-smoke.php", diff --git a/src/Runtime/AgentConversationLoop.php b/src/Runtime/AgentConversationLoop.php index 7b89e67..b65afe6 100644 --- a/src/Runtime/AgentConversationLoop.php +++ b/src/Runtime/AgentConversationLoop.php @@ -11,6 +11,7 @@ use AgentsAPI\AI\Tools\ToolExecutionCore; use AgentsAPI\AI\Tools\ToolExecutionResult; use AgentsAPI\AI\Tools\ToolExecutorInterface; +use AgentsAPI\Core\Database\Chat\ConversationTranscriptLockInterface; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -47,6 +48,9 @@ class AgentConversationLoop { * - `tool_executor` (ToolExecutorInterface|null): Tool execution adapter. * - `tool_declarations` (array|null): Tool declarations keyed by name. * - `completion_policy` (AgentConversationCompletionPolicyInterface|null): Typed completion policy. + * - `transcript_lock` or `transcript_lock_store` (ConversationTranscriptLockInterface|null): Optional transcript lock. + * - `transcript_session_id` (string): Session ID to lock when a lock store is provided. + * - `transcript_lock_ttl` (int): Lock TTL in seconds. Defaults to 300. * - `transcript_persister` (AgentConversationTranscriptPersisterInterface|null): Transcript persister. * - `on_event` (callable|null): Caller-owned lifecycle event sink `fn(string $event, array $payload)`. * @@ -63,7 +67,12 @@ public static function run( array $messages, callable $turn_runner, array $optio $tool_declarations = self::resolve_tool_declarations( $options ); $completion_policy = self::resolve_completion_policy( $options ); $transcript_persister = self::resolve_transcript_persister( $options ); + $transcript_lock = self::resolve_transcript_lock( $options ); $on_event = self::resolve_event_sink( $options ); + $request = self::resolve_request( $messages, $options ); + $lock_session_id = self::resolve_lock_session_id( $options, $request ); + $lock_ttl = self::resolve_lock_ttl( $options ); + $lock_token = null; $budget_resolution = self::resolve_budgets( $options, $max_turns ); $budgets = $budget_resolution['budgets']; $has_explicit_turns = $budget_resolution['has_explicit_turns']; @@ -74,150 +83,177 @@ public static function run( array $messages, callable $turn_runner, array $optio $conversation_complete = false; $exceeded_budget = null; - for ( $turn = 1; $turn <= $max_turns; ++$turn ) { - $turn_context = $context; - $turn_context['turn'] = $turn; - - self::emit_event( $on_event, 'turn_started', array( - 'turn' => $turn, - 'max_turns' => $max_turns, - 'message_count' => count( $messages ), - ) ); - - $compaction = self::maybe_compact( $messages, $options ); - $messages = $compaction['messages']; - $events = array_merge( $events, $compaction['events'] ); - - try { - $result = call_user_func( $turn_runner, $messages, $turn_context ); - } catch ( \Throwable $error ) { - self::emit_event( $on_event, 'failed', array( - 'turn' => $turn, - 'error' => $error->getMessage(), + if ( null !== $transcript_lock && '' !== $lock_session_id ) { + $lock_token = $transcript_lock->acquire_session_lock( $lock_session_id, $lock_ttl ); + if ( null === $lock_token || '' === $lock_token ) { + self::emit_event( $on_event, 'transcript_lock_contention', array( + 'session_id' => $lock_session_id, ) ); - $failure_result = AgentConversationResult::normalize( array( + return AgentConversationResult::normalize( array( 'messages' => $messages, - 'tool_execution_results' => $tool_results, - 'events' => $events, + 'tool_execution_results' => array(), + 'events' => array(), + 'status' => 'transcript_lock_contention', ) ); - - self::persist_transcript( $transcript_persister, $messages, $options, $failure_result ); - throw $error; } + } - if ( ! is_array( $result ) ) { - $error = new \InvalidArgumentException( 'invalid_agent_conversation_loop: turn runner must return an array' ); - - self::emit_event( $on_event, 'failed', array( - 'turn' => $turn, - 'error' => $error->getMessage(), + try { + for ( $turn = 1; $turn <= $max_turns; ++$turn ) { + $turn_context = $context; + $turn_context['turn'] = $turn; + + self::emit_event( $on_event, 'turn_started', array( + 'turn' => $turn, + 'max_turns' => $max_turns, + 'message_count' => count( $messages ), ) ); - self::persist_transcript( $transcript_persister, $messages, $options, array( - 'messages' => $messages, - 'tool_execution_results' => $tool_results, - 'events' => $events, - ) ); + $compaction = self::maybe_compact( $messages, $options ); + $messages = $compaction['messages']; + $events = array_merge( $events, $compaction['events'] ); + + try { + $result = call_user_func( $turn_runner, $messages, $turn_context ); + } catch ( \Throwable $error ) { + self::emit_event( $on_event, 'failed', array( + 'turn' => $turn, + 'error' => $error->getMessage(), + ) ); + + $failure_result = AgentConversationResult::normalize( array( + 'messages' => $messages, + 'tool_execution_results' => $tool_results, + 'events' => $events, + ) ); + + self::persist_transcript( $transcript_persister, $messages, $options, $failure_result ); + throw $error; + } - throw $error; - } + if ( ! is_array( $result ) ) { + $error = new \InvalidArgumentException( 'invalid_agent_conversation_loop: turn runner must return an array' ); - // When mediation is enabled, the turn runner returns tool_calls - // and the loop handles execution. Otherwise, the legacy path applies. - if ( $mediation_enabled && isset( $result['tool_calls'] ) && is_array( $result['tool_calls'] ) ) { - $mediation_result = self::mediate_tool_calls( - $result, - $tool_executor, - $tool_declarations, - $completion_policy, - $turn_context, - $turn, - $on_event, - $budgets - ); + self::emit_event( $on_event, 'failed', array( + 'turn' => $turn, + 'error' => $error->getMessage(), + ) ); - $messages = $mediation_result['messages']; - $tool_results = array_merge( $tool_results, $mediation_result['tool_execution_results'] ); - $events = array_merge( $events, $mediation_result['events'] ); - $conversation_complete = $mediation_result['conversation_complete']; - $exceeded_budget = $mediation_result['exceeded_budget']; - } else { - // Legacy path: turn runner handles everything internally. - $result = AgentConversationResult::normalize( $result ); - $messages = $result['messages']; - $tool_results = array_merge( $tool_results, $result['tool_execution_results'] ); - $events = array_merge( $events, self::normalize_events( $result['events'] ?? array() ) ); - - // Apply completion policy to tool results from the turn runner - // when the loop owns policy but the turn runner handled execution. - if ( null !== $completion_policy && ! empty( $result['tool_execution_results'] ) ) { - foreach ( $result['tool_execution_results'] as $tool_exec_result ) { - $tool_name = $tool_exec_result['tool_name'] ?? ''; - $tool_def = $tool_declarations[ $tool_name ] ?? null; - $decision = $completion_policy->recordToolResult( - $tool_name, - is_array( $tool_def ) ? $tool_def : null, - $tool_exec_result, - $turn_context, - $turn - ); - if ( $decision->isComplete() ) { - $conversation_complete = true; - break; + self::persist_transcript( $transcript_persister, $messages, $options, array( + 'messages' => $messages, + 'tool_execution_results' => $tool_results, + 'events' => $events, + ) ); + + throw $error; + } + + // When mediation is enabled, the turn runner returns tool_calls + // and the loop handles execution. Otherwise, the legacy path applies. + if ( $mediation_enabled && isset( $result['tool_calls'] ) && is_array( $result['tool_calls'] ) ) { + $mediation_result = self::mediate_tool_calls( + $result, + $tool_executor, + $tool_declarations, + $completion_policy, + $turn_context, + $turn, + $on_event, + $budgets + ); + + $messages = $mediation_result['messages']; + $tool_results = array_merge( $tool_results, $mediation_result['tool_execution_results'] ); + $events = array_merge( $events, $mediation_result['events'] ); + $conversation_complete = $mediation_result['conversation_complete']; + $exceeded_budget = $mediation_result['exceeded_budget']; + } else { + // Legacy path: turn runner handles everything internally. + $result = AgentConversationResult::normalize( $result ); + $messages = $result['messages']; + $tool_results = array_merge( $tool_results, $result['tool_execution_results'] ); + $events = array_merge( $events, self::normalize_events( $result['events'] ?? array() ) ); + + // Apply completion policy to tool results from the turn runner + // when the loop owns policy but the turn runner handled execution. + if ( null !== $completion_policy && ! empty( $result['tool_execution_results'] ) ) { + foreach ( $result['tool_execution_results'] as $tool_exec_result ) { + $tool_name = $tool_exec_result['tool_name'] ?? ''; + $tool_def = $tool_declarations[ $tool_name ] ?? null; + $decision = $completion_policy->recordToolResult( + $tool_name, + is_array( $tool_def ) ? $tool_def : null, + $tool_exec_result, + $turn_context, + $turn + ); + if ( $decision->isComplete() ) { + $conversation_complete = true; + break; + } } } } - } - // Stop conditions: budget exceeded, completion policy, or legacy should_continue. - if ( null !== $exceeded_budget ) { - break; - } + // Stop conditions: budget exceeded, completion policy, or legacy should_continue. + if ( null !== $exceeded_budget ) { + break; + } - if ( $conversation_complete ) { - break; - } + if ( $conversation_complete ) { + break; + } - // Increment the turns budget after a completed turn. - // Synthesized turns budgets (from max_turns) break the loop silently - // to preserve backwards compatibility. Explicit turns budgets signal - // budget_exceeded so callers know the stop reason. - $turns_exceeded = self::increment_budget( $budgets, 'turns', $has_explicit_turns ? $on_event : null ); - if ( null !== $turns_exceeded ) { - if ( $has_explicit_turns ) { - $exceeded_budget = $turns_exceeded; + // Increment the turns budget after a completed turn. + // Synthesized turns budgets (from max_turns) break the loop silently + // to preserve backwards compatibility. Explicit turns budgets signal + // budget_exceeded so callers know the stop reason. + $turns_exceeded = self::increment_budget( $budgets, 'turns', $has_explicit_turns ? $on_event : null ); + if ( null !== $turns_exceeded ) { + if ( $has_explicit_turns ) { + $exceeded_budget = $turns_exceeded; + } + break; } - break; - } - if ( ! is_callable( $should_continue ) || ! call_user_func( $should_continue, $result, $turn_context ) ) { - break; + if ( ! is_callable( $should_continue ) || ! call_user_func( $should_continue, $result, $turn_context ) ) { + break; + } } - } - $final_result_data = array( - 'messages' => $messages, - 'tool_execution_results' => $tool_results, - 'events' => $events, - ); + $final_result_data = array( + 'messages' => $messages, + 'tool_execution_results' => $tool_results, + 'events' => $events, + ); - if ( null !== $exceeded_budget ) { - $final_result_data['status'] = 'budget_exceeded'; - $final_result_data['budget'] = $exceeded_budget; - } + if ( null !== $exceeded_budget ) { + $final_result_data['status'] = 'budget_exceeded'; + $final_result_data['budget'] = $exceeded_budget; + } - $final_result = AgentConversationResult::normalize( $final_result_data ); + $final_result = AgentConversationResult::normalize( $final_result_data ); - self::persist_transcript( $transcript_persister, $messages, $options, $final_result ); + self::persist_transcript( $transcript_persister, $messages, $options, $final_result ); - self::emit_event( $on_event, 'completed', array( - 'turn' => $turn, - 'message_count' => count( $messages ), - 'tool_results' => count( $tool_results ), - ) ); + self::emit_event( $on_event, 'completed', array( + 'turn' => $turn, + 'message_count' => count( $messages ), + 'tool_results' => count( $tool_results ), + ) ); - return $final_result; + return $final_result; + } finally { + if ( null !== $transcript_lock && null !== $lock_token && '' !== $lock_session_id ) { + try { + $transcript_lock->release_session_lock( $lock_session_id, $lock_token ); + } catch ( \Throwable $error ) { + // Lock release failures must not change loop results. + unset( $error ); + } + } + } } /** @@ -387,19 +423,7 @@ private static function persist_transcript( return; } - $request = $options['request'] ?? null; - - if ( ! $request instanceof AgentConversationRequest ) { - // Build a minimal request from the loop options for persistence context. - $request = new AgentConversationRequest( - $messages, - array(), - null, - isset( $options['context'] ) && is_array( $options['context'] ) ? $options['context'] : array(), - array(), - self::max_turns( $options['max_turns'] ?? 1 ) - ); - } + $request = self::resolve_request( $messages, $options ); try { $persister->persist( $messages, $request, $result ); @@ -409,6 +433,29 @@ private static function persist_transcript( } } + /** + * Resolve the request object from options, or build a minimal one. + * + * @param array $messages Current messages. + * @param array $options Loop options. + * @return AgentConversationRequest + */ + private static function resolve_request( array $messages, array $options ): AgentConversationRequest { + $request = $options['request'] ?? null; + if ( $request instanceof AgentConversationRequest ) { + return $request; + } + + return new AgentConversationRequest( + $messages, + array(), + null, + isset( $options['context'] ) && is_array( $options['context'] ) ? $options['context'] : array(), + array(), + self::max_turns( $options['max_turns'] ?? 1 ) + ); + } + /** * Emit a lifecycle event through the caller sink and WordPress observers. * @@ -493,6 +540,59 @@ private static function resolve_transcript_persister( array $options ): ?AgentCo return $persister instanceof AgentConversationTranscriptPersisterInterface ? $persister : null; } + /** + * Resolve the transcript lock primitive from options. + * + * @param array $options Loop options. + * @return ConversationTranscriptLockInterface|null + */ + private static function resolve_transcript_lock( array $options ): ?ConversationTranscriptLockInterface { + foreach ( array( 'transcript_lock', 'transcript_lock_store', 'transcript_store' ) as $key ) { + $lock = $options[ $key ] ?? null; + if ( $lock instanceof ConversationTranscriptLockInterface ) { + return $lock; + } + } + + return null; + } + + /** + * Resolve the transcript session ID to lock. + * + * @param array $options Loop options. + * @param AgentConversationRequest $request Request object. + * @return string + */ + private static function resolve_lock_session_id( array $options, AgentConversationRequest $request ): string { + foreach ( array( 'transcript_session_id', 'session_id', 'transcript_id' ) as $key ) { + $value = $options[ $key ] ?? null; + if ( is_string( $value ) && '' !== trim( $value ) ) { + return trim( $value ); + } + } + + $metadata = $request->metadata(); + foreach ( array( 'transcript_session_id', 'session_id', 'transcript_id' ) as $key ) { + $value = $metadata[ $key ] ?? null; + if ( is_string( $value ) && '' !== trim( $value ) ) { + return trim( $value ); + } + } + + return ''; + } + + /** + * Resolve transcript lock TTL. + * + * @param array $options Loop options. + * @return int TTL in seconds. + */ + private static function resolve_lock_ttl( array $options ): int { + return max( 1, (int) ( $options['transcript_lock_ttl'] ?? 300 ) ); + } + /** * Resolve the event sink from options. * diff --git a/src/Transcripts/ConversationTranscriptLockInterface.php b/src/Transcripts/ConversationTranscriptLockInterface.php new file mode 100644 index 0000000..a644cc6 --- /dev/null +++ b/src/Transcripts/ConversationTranscriptLockInterface.php @@ -0,0 +1,43 @@ + 'AgentsAPI\\AI\\NullAgentConversationTranscriptPersister', 'DataMachine\\Engine\\AI\\AgentConversationCompaction' => 'AgentsAPI\\AI\\AgentConversationCompaction', 'DataMachine\\Engine\\AI\\AgentConversationResult' => 'AgentsAPI\\AI\\AgentConversationResult', + 'DataMachine\\Core\\Database\\Chat\\ConversationTranscriptLockInterface' => 'AgentsAPI\\Core\\Database\\Chat\\ConversationTranscriptLockInterface', 'DataMachine\\Engine\\AI\\Tools\\RuntimeToolDeclaration' => 'AgentsAPI\\AI\\Tools\\RuntimeToolDeclaration', 'DataMachine\\Engine\\AI\\Tools\\ToolCall' => 'AgentsAPI\\AI\\Tools\\ToolCall', 'DataMachine\\Engine\\AI\\Tools\\ToolParameters' => 'AgentsAPI\\AI\\Tools\\ToolParameters', diff --git a/tests/conversation-loop-transcript-persister-smoke.php b/tests/conversation-loop-transcript-persister-smoke.php index e55bad9..6b5fb39 100644 --- a/tests/conversation-loop-transcript-persister-smoke.php +++ b/tests/conversation-loop-transcript-persister-smoke.php @@ -174,4 +174,115 @@ static function ( array $messages ): array { $passes ); +echo "\n[6] Transcript lock wraps turn execution and persistence when available:\n"; +$lock_log = array(); +$persister_log = array(); +$locking_store = new class( $lock_log ) implements AgentsAPI\Core\Database\Chat\ConversationTranscriptLockInterface { + /** @var array Log reference. */ + private array $log; + + public function __construct( array &$log ) { + $this->log = &$log; + } + + public function acquire_session_lock( string $session_id, int $ttl_seconds = 300 ): ?string { + $this->log[] = array( 'acquire', $session_id, $ttl_seconds ); + return 'lock-token'; + } + + public function release_session_lock( string $session_id, string $lock_token ): bool { + $this->log[] = array( 'release', $session_id, $lock_token ); + return true; + } +}; +$locking_persister = new class( $persister_log, $lock_log ) implements AgentsAPI\AI\AgentConversationTranscriptPersisterInterface { + /** @var array Persister log reference. */ + private array $persister_log; + + /** @var array Lock sequencing log reference. */ + private array $lock_log; + + public function __construct( array &$persister_log, array &$lock_log ) { + $this->persister_log = &$persister_log; + $this->lock_log = &$lock_log; + } + + public function persist( array $messages, AgentsAPI\AI\AgentConversationRequest $request, array $result ): string { + unset( $request, $result ); + $this->persister_log[] = count( $messages ); + $this->lock_log[] = array( 'persist' ); + + return 'locked-transcript'; + } +}; + +$result6 = AgentsAPI\AI\AgentConversationLoop::run( + array( array( 'role' => 'user', 'content' => 'locked' ) ), + static function ( array $messages ) use ( &$lock_log ): array { + $lock_log[] = array( 'runner' ); + $messages[] = AgentsAPI\AI\AgentMessageEnvelope::text( 'assistant', 'locked ok' ); + + return array( + 'messages' => $messages, + 'tool_execution_results' => array(), + 'events' => array(), + ); + }, + array( + 'max_turns' => 1, + 'transcript_session_id' => 'session-lock-1', + 'transcript_lock' => $locking_store, + 'transcript_lock_ttl' => 45, + 'transcript_persister' => $locking_persister, + ) +); + +agents_api_smoke_assert_equals( 2, count( $result6['messages'] ), 'locked loop still returns runner result', $failures, $passes ); +agents_api_smoke_assert_equals( 1, count( $persister_log ), 'persister still runs while lock is held', $failures, $passes ); +agents_api_smoke_assert_equals( + array( + array( 'acquire', 'session-lock-1', 45 ), + array( 'runner' ), + array( 'persist' ), + array( 'release', 'session-lock-1', 'lock-token' ), + ), + $lock_log, + 'lock is acquired before runner and released after persistence', + $failures, + $passes +); + +echo "\n[7] Lock contention returns without running the turn or persister:\n"; +$persister_log = array(); +$contention_runs = 0; +$contention_lock = new class() implements AgentsAPI\Core\Database\Chat\ConversationTranscriptLockInterface { + public function acquire_session_lock( string $session_id, int $ttl_seconds = 300 ): ?string { + unset( $session_id, $ttl_seconds ); + return null; + } + + public function release_session_lock( string $session_id, string $lock_token ): bool { + unset( $session_id, $lock_token ); + return false; + } +}; + +$result7 = AgentsAPI\AI\AgentConversationLoop::run( + array( array( 'role' => 'user', 'content' => 'contended' ) ), + static function () use ( &$contention_runs ): array { + ++$contention_runs; + return array( 'messages' => array() ); + }, + array( + 'max_turns' => 1, + 'transcript_session_id' => 'session-lock-2', + 'transcript_lock' => $contention_lock, + 'transcript_persister' => $persister, + ) +); + +agents_api_smoke_assert_equals( 'transcript_lock_contention', $result7['status'] ?? '', 'contention result is explicit', $failures, $passes ); +agents_api_smoke_assert_equals( 0, $contention_runs, 'turn runner is skipped on lock contention', $failures, $passes ); +agents_api_smoke_assert_equals( 0, count( $persister_log ), 'persister is skipped on lock contention', $failures, $passes ); + agents_api_smoke_finish( 'Agents API conversation loop transcript persister', $failures, $passes ); diff --git a/tests/conversation-transcript-lock-smoke.php b/tests/conversation-transcript-lock-smoke.php new file mode 100644 index 0000000..c9a11f1 --- /dev/null +++ b/tests/conversation-transcript-lock-smoke.php @@ -0,0 +1,88 @@ + */ + private array $locks = array(); + + /** @var int Token counter. */ + private int $counter = 0; + + public function __construct( int &$now ) { + $this->now = &$now; + } + + public function acquire_session_lock( string $session_id, int $ttl_seconds = 300 ): ?string { + $active = $this->locks[ $session_id ] ?? null; + if ( null !== $active && $active['expires_at'] > $this->now ) { + return null; + } + + $token = 'token-' . ++$this->counter; + $this->locks[ $session_id ] = array( + 'token' => $token, + 'expires_at' => $this->now + max( 1, $ttl_seconds ), + ); + + return $token; + } + + public function release_session_lock( string $session_id, string $lock_token ): bool { + $active = $this->locks[ $session_id ] ?? null; + if ( null === $active || $active['token'] !== $lock_token ) { + return false; + } + + unset( $this->locks[ $session_id ] ); + return true; + } +}; + +echo "\n[1] Acquire then release succeeds:\n"; +$token = $lock->acquire_session_lock( 'session-1', 30 ); +agents_api_smoke_assert_equals( true, is_string( $token ) && '' !== $token, 'acquire returns a lock token', $failures, $passes ); +agents_api_smoke_assert_equals( true, $lock->release_session_lock( 'session-1', (string) $token ), 'release accepts the active token', $failures, $passes ); + +echo "\n[2] Contention returns null:\n"; +$token_a = $lock->acquire_session_lock( 'session-2', 30 ); +$token_b = $lock->acquire_session_lock( 'session-2', 30 ); +agents_api_smoke_assert_equals( true, is_string( $token_a ) && '' !== $token_a, 'first contender acquires lock', $failures, $passes ); +agents_api_smoke_assert_equals( null, $token_b, 'second contender is denied while lock is active', $failures, $passes ); + +echo "\n[3] TTL expiry permits reacquisition:\n"; +$clock += 31; +$token_c = $lock->acquire_session_lock( 'session-2', 30 ); +agents_api_smoke_assert_equals( true, is_string( $token_c ) && '' !== $token_c && $token_c !== $token_a, 'expired lock is reclaimable with new token', $failures, $passes ); + +echo "\n[4] Stale token release is rejected:\n"; +agents_api_smoke_assert_equals( false, $lock->release_session_lock( 'session-2', (string) $token_a ), 'stale token does not release reacquired lock', $failures, $passes ); +agents_api_smoke_assert_equals( true, $lock->release_session_lock( 'session-2', (string) $token_c ), 'current token still releases after stale rejection', $failures, $passes ); + +echo "\n[5] Null lock explicitly declines lock ownership:\n"; +$null_lock = new AgentsAPI\Core\Database\Chat\NullConversationTranscriptLock(); +agents_api_smoke_assert_equals( null, $null_lock->acquire_session_lock( 'session-3', 30 ), 'null lock returns null on acquire', $failures, $passes ); +agents_api_smoke_assert_equals( false, $null_lock->release_session_lock( 'session-3', 'token' ), 'null lock returns false on release', $failures, $passes ); + +agents_api_smoke_finish( 'Agents API conversation transcript lock', $failures, $passes );