From 2da2085cd3df40eafbe980d61d8378e2af300dbc Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 4 May 2026 09:43:20 -0400 Subject: [PATCH] feat(approvals): promote durable pending action contracts --- README.md | 27 +- agents-api.php | 1 + src/Approvals/PendingAction.php | 237 ++++++++---------- .../PendingActionHandlerInterface.php | 30 ++- .../PendingActionResolverInterface.php | 8 +- src/Approvals/PendingActionStatus.php | 57 +++++ src/Approvals/PendingActionStoreInterface.php | 62 ++++- tests/approval-action-value-shape-smoke.php | 79 ++++-- tests/approval-resolver-contract-smoke.php | 62 ++++- tests/bootstrap-smoke.php | 1 + tests/pending-action-store-contract-smoke.php | 37 ++- 11 files changed, 412 insertions(+), 189 deletions(-) create mode 100644 src/Approvals/PendingActionStatus.php diff --git a/README.md b/README.md index 919b2c0..53a7e0f 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,12 @@ wp_register_agent( - `AgentsAPI\AI\Tools\ToolExecutorInterface` - `AgentsAPI\AI\Tools\ToolExecutionCore` - `AgentsAPI\AI\Tools\ToolExecutionResult` +- `AgentsAPI\AI\Approvals\ApprovalDecision` +- `AgentsAPI\AI\Approvals\PendingAction` +- `AgentsAPI\AI\Approvals\PendingActionStatus` +- `AgentsAPI\AI\Approvals\PendingActionStoreInterface` +- `AgentsAPI\AI\Approvals\PendingActionResolverInterface` +- `AgentsAPI\AI\Approvals\PendingActionHandlerInterface` - `AgentsAPI\Core\Database\Chat\ConversationTranscriptStoreInterface` - `AgentsAPI\Core\FilesRepository\AgentMemoryStoreInterface` and memory value objects @@ -272,13 +278,24 @@ The loop treats all adapter inputs and outputs as JSON-friendly arrays so produc Agents API owns generic approval primitives for runtime actions that need explicit user or policy approval before a consumer applies them. The lifecycle is: - A runtime or tool proposes an action instead of applying it immediately. -- The proposal is emitted or stored as a generic pending action value. -- A UI or user accepts or rejects the pending action. -- The consumer adapter resolves the decision and applies or discards the proposal through its own product-specific handler. +- The proposal is emitted or stored as a generic `PendingAction` value. +- A UI, user, policy service, or resolver actor accepts or rejects the pending action. +- The consumer adapter resolves the decision, runs handler-level permission checks, applies or discards the proposal through its own product-specific handler, and records terminal audit metadata. -Agents API owns the reusable contract shape only: value objects and interfaces for pending actions, the JSON-friendly proposal and decision shape, policy vocabulary for approval requirements, and a typed `approval_required` envelope that runtimes can return without knowing where the proposal will be stored or displayed. +Agents API owns the reusable contract shape only: value objects and interfaces for pending actions, the JSON-friendly proposal and decision shape, status vocabulary, policy vocabulary for approval requirements, and a typed `approval_required` envelope that runtimes can return without knowing where the proposal will be stored or displayed. -Consuming products own the concrete materialization: durable storage, REST routes, abilities or tool surfaces, chat/admin UI, permission ceilings, audit records, queues, jobs, workflows, and product-specific apply/reject handlers. Those concerns belong in adapters because they depend on each product's UX, authorization model, and operational semantics. +Durable pending action records include: + +- `action_id`, `kind`, `summary`, `preview`, and `apply_input`. +- `workspace`, `agent`, and `creator` actor/provenance fields. +- `status` using `PendingActionStatus`: `pending`, `accepted`, `rejected`, `expired`, or `deleted`. +- `created_at`, `expires_at`, and terminal `resolved_at` timestamps. +- `resolver`, `resolution_result`, `resolution_error`, and `resolution_metadata` audit fields. +- Generic `metadata` for JSON-serializable caller context that is not part of handler replay input. + +`PendingActionStoreInterface` defines the durable queue/audit surface: `store`, `get`, `list`, `summary`, `record_resolution`, `expire`, and `delete`. `PendingActionResolverInterface` defines accept/reject resolution with an explicit resolver identity. `PendingActionHandlerInterface` lets product handlers enforce handler-level permission checks before applying or rejecting a stored action. + +Consuming products own the concrete materialization: database tables, REST routes, abilities or tool surfaces, chat/admin UI, permission ceilings, queues, jobs, workflows, and product-specific apply/reject handlers. Those concerns belong in adapters because they depend on each product's UX, authorization model, and operational semantics. Package artifacts can also describe a `diff_callback` so packages can generate reviewable diffs for installer or updater flows. That artifact is related to approval because it helps produce human-reviewable change previews, but it is not the same primitive as runtime pending-action approval. `diff_callback` belongs to package artifact review; `approval_required` belongs to a live runtime/tool proposal that must be accepted or rejected before the consumer applies it. diff --git a/agents-api.php b/agents-api.php index ec7a65d..ced5125 100644 --- a/agents-api.php +++ b/agents-api.php @@ -41,6 +41,7 @@ require_once AGENTS_API_PATH . 'src/Identity/MaterializedAgentIdentityStoreInterface.php'; require_once AGENTS_API_PATH . 'src/Transcripts/ConversationTranscriptStoreInterface.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'; require_once AGENTS_API_PATH . 'src/Approvals/ApprovalDecision.php'; require_once AGENTS_API_PATH . 'src/Approvals/PendingActionHandlerInterface.php'; diff --git a/src/Approvals/PendingAction.php b/src/Approvals/PendingAction.php index 9f7fe4f..0121178 100644 --- a/src/Approvals/PendingAction.php +++ b/src/Approvals/PendingAction.php @@ -10,75 +10,19 @@ defined( 'ABSPATH' ) || exit; /** - * Represents a proposed action awaiting approval. + * Represents a proposed action and its durable resolution audit fields. */ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Validation exceptions are not rendered output. class PendingAction { - /** @var string */ - private $action_id; - - /** @var string */ - private $kind; - - /** @var string */ - private $summary; - - /** @var mixed */ - private $preview; - - /** @var mixed */ - private $apply_input; - - /** @var string|null */ - private $created_by; - - /** @var string|null */ - private $agent_id; - - /** @var array */ - private $context; - - /** @var string */ - private $created_at; - - /** @var string|null */ - private $expires_at; + /** @var array */ + private array $data; /** - * @param string $action_id Stable action identifier. - * @param string $kind Generic action kind. - * @param string $summary Human-readable summary. - * @param mixed $preview JSON-serializable preview payload. - * @param mixed $apply_input JSON-serializable apply payload. - * @param string|null $created_by Optional creator identifier. - * @param string|null $agent_id Optional agent identifier. - * @param mixed $context Optional JSON-serializable context array. - * @param string $created_at Creation timestamp string. - * @param string|null $expires_at Optional expiration timestamp string. + * @param array $data Canonical pending action data. */ - public function __construct( - $action_id, - $kind, - $summary, - $preview, - $apply_input, - $created_by, - $agent_id, - $context, - $created_at, - $expires_at - ) { - $this->action_id = self::normalize_string( $action_id, 'action_id' ); - $this->kind = self::normalize_string( $kind, 'kind' ); - $this->summary = self::normalize_string( $summary, 'summary' ); - $this->preview = self::normalize_json_value( $preview, 'preview' ); - $this->apply_input = self::normalize_json_value( $apply_input, 'apply_input' ); - $this->created_by = self::normalize_optional_string( $created_by, 'created_by' ); - $this->agent_id = self::normalize_optional_string( $agent_id, 'agent_id' ); - $this->context = self::normalize_json_array( $context, 'context' ); - $this->created_at = self::normalize_string( $created_at, 'created_at' ); - $this->expires_at = self::normalize_optional_string( $expires_at, 'expires_at' ); + private function __construct( array $data ) { + $this->data = $data; } /** @@ -88,18 +32,29 @@ public function __construct( * @return self */ public static function from_array( array $action ): self { - return new self( - self::required_value( $action, 'action_id' ), - self::required_value( $action, 'kind' ), - self::required_value( $action, 'summary' ), - self::required_value( $action, 'preview' ), - self::required_value( $action, 'apply_input' ), - $action['created_by'] ?? null, - $action['agent_id'] ?? null, - $action['context'] ?? array(), - self::required_value( $action, 'created_at' ), - $action['expires_at'] ?? null + $data = array( + 'action_id' => self::normalize_string( self::required_value( $action, 'action_id' ), 'action_id' ), + 'kind' => self::normalize_string( self::required_value( $action, 'kind' ), 'kind' ), + 'summary' => self::normalize_string( self::required_value( $action, 'summary' ), 'summary' ), + 'preview' => self::normalize_json_value( self::required_value( $action, 'preview' ), 'preview' ), + 'apply_input' => self::normalize_json_value( self::required_value( $action, 'apply_input' ), 'apply_input' ), + 'workspace' => self::normalize_optional_string( $action['workspace'] ?? null, 'workspace' ), + 'agent' => self::normalize_optional_string( $action['agent'] ?? null, 'agent' ), + 'creator' => self::normalize_optional_string( $action['creator'] ?? null, 'creator' ), + 'status' => self::normalize_status( $action['status'] ?? PendingActionStatus::PENDING ), + 'created_at' => self::normalize_string( self::required_value( $action, 'created_at' ), 'created_at' ), + 'expires_at' => self::normalize_optional_string( $action['expires_at'] ?? null, 'expires_at' ), + 'resolved_at' => self::normalize_optional_string( $action['resolved_at'] ?? null, 'resolved_at' ), + 'resolver' => self::normalize_optional_string( $action['resolver'] ?? null, 'resolver' ), + 'resolution_result' => self::normalize_json_value( $action['resolution_result'] ?? null, 'resolution_result' ), + 'resolution_error' => self::normalize_optional_string( $action['resolution_error'] ?? null, 'resolution_error' ), + 'resolution_metadata' => self::normalize_json_array( $action['resolution_metadata'] ?? array(), 'resolution_metadata' ), + 'metadata' => self::normalize_json_array( $action['metadata'] ?? array(), 'metadata' ), ); + + self::assert_resolution_audit_is_consistent( $data ); + + return new self( $data ); } /** @@ -108,88 +63,81 @@ public static function from_array( array $action ): self { * @return array */ public function to_array(): array { - return array( - 'action_id' => $this->action_id, - 'kind' => $this->kind, - 'summary' => $this->summary, - 'preview' => $this->preview, - 'apply_input' => $this->apply_input, - 'created_by' => $this->created_by, - 'agent_id' => $this->agent_id, - 'context' => $this->context, - 'created_at' => $this->created_at, - 'expires_at' => $this->expires_at, - ); + return $this->data; } - /** - * @return string - */ public function get_action_id(): string { - return $this->action_id; + return $this->data['action_id']; } - /** - * @return string - */ public function get_kind(): string { - return $this->kind; + return $this->data['kind']; } - /** - * @return string - */ public function get_summary(): string { - return $this->summary; + return $this->data['summary']; } - /** - * @return mixed - */ public function get_preview() { - return $this->preview; + return $this->data['preview']; } - /** - * @return mixed - */ public function get_apply_input() { - return $this->apply_input; + return $this->data['apply_input']; } - /** - * @return string|null - */ - public function get_created_by(): ?string { - return $this->created_by; + public function get_workspace(): ?string { + return $this->data['workspace']; } - /** - * @return string|null - */ - public function get_agent_id(): ?string { - return $this->agent_id; + public function get_agent(): ?string { + return $this->data['agent']; } - /** - * @return array - */ - public function get_context(): array { - return $this->context; + public function get_creator(): ?string { + return $this->data['creator']; + } + + public function get_status(): string { + return $this->data['status']; + } + + public function get_created_at(): string { + return $this->data['created_at']; + } + + public function get_expires_at(): ?string { + return $this->data['expires_at']; + } + + public function get_resolved_at(): ?string { + return $this->data['resolved_at']; + } + + public function get_resolver(): ?string { + return $this->data['resolver']; + } + + public function get_resolution_result() { + return $this->data['resolution_result']; + } + + public function get_resolution_error(): ?string { + return $this->data['resolution_error']; } /** - * @return string + * @return array */ - public function get_created_at(): string { - return $this->created_at; + public function get_resolution_metadata(): array { + return $this->data['resolution_metadata']; } /** - * @return string|null + * @return array */ - public function get_expires_at(): ?string { - return $this->expires_at; + public function get_metadata(): array { + return $this->data['metadata']; } /** @@ -265,6 +213,43 @@ private static function normalize_json_value( $value, string $field ) { return $value; } + /** + * Normalize a pending action status with the value-object error prefix. + * + * @param mixed $value Raw status. + * @return string + */ + private static function normalize_status( $value ): string { + if ( ! is_string( $value ) ) { + throw new \InvalidArgumentException( 'invalid_ai_pending_action: status must be a string' ); + } + + try { + return PendingActionStatus::normalize( $value ); + } catch ( \InvalidArgumentException $error ) { + throw new \InvalidArgumentException( 'invalid_ai_pending_action: status must be pending, accepted, rejected, expired, or deleted', 0, $error ); + } + } + + /** + * Terminal statuses must carry resolver and resolved_at audit fields. + * + * @param array $data Normalized data. + */ + private static function assert_resolution_audit_is_consistent( array $data ): void { + if ( ! PendingActionStatus::is_terminal( $data['status'] ) ) { + return; + } + + if ( null === $data['resolved_at'] ) { + throw new \InvalidArgumentException( 'invalid_ai_pending_action: resolved_at is required for terminal status' ); + } + + if ( null === $data['resolver'] && PendingActionStatus::EXPIRED !== $data['status'] ) { + throw new \InvalidArgumentException( 'invalid_ai_pending_action: resolver is required for terminal status' ); + } + } + /** * Validate JSON serializability with a pure-PHP fallback for smokes. * diff --git a/src/Approvals/PendingActionHandlerInterface.php b/src/Approvals/PendingActionHandlerInterface.php index a7aa3ad..8f805e3 100644 --- a/src/Approvals/PendingActionHandlerInterface.php +++ b/src/Approvals/PendingActionHandlerInterface.php @@ -11,18 +11,32 @@ interface PendingActionHandlerInterface { + /** + * Check whether the resolver may resolve a stored pending action. + * + * Resolver implementations SHOULD call this before applying or rejecting an + * action. Returning false denies resolution without encoding product policy in + * Agents API itself. + * + * @param PendingAction $action Stored pending action. + * @param ApprovalDecision $decision Accepted/rejected decision. + * @param array $payload Fresh resolver payload supplied with the decision. + * @param array $context Optional caller context. + * @return bool Whether resolution is allowed. + */ + public function can_resolve_pending_action( PendingAction $action, ApprovalDecision $decision, array $payload = array(), array $context = array() ): bool; + /** * Resolve a stored pending action with a caller-provided decision. * - * The apply input is the implementation-owned data captured when the action - * was queued. The payload is fresh resolver input supplied with the decision. - * Permission checks, persistence, and transport concerns stay with callers. + * Product-specific apply/reject behavior stays in consumer handlers. Agents API + * only defines the generic handoff shape. * - * @param ApprovalDecision $decision Accepted/rejected decision. - * @param array $apply_input Stored apply input for the pending action. - * @param array $payload Fresh resolver payload supplied with the decision. - * @param array $context Optional caller context. + * @param PendingAction $action Stored pending action. + * @param ApprovalDecision $decision Accepted/rejected decision. + * @param array $payload Fresh resolver payload supplied with the decision. + * @param array $context Optional caller context. * @return mixed Generic implementation result. */ - public function handle_pending_action( ApprovalDecision $decision, array $apply_input, array $payload = array(), array $context = array() ): mixed; + public function handle_pending_action( PendingAction $action, ApprovalDecision $decision, array $payload = array(), array $context = array() ): mixed; } diff --git a/src/Approvals/PendingActionResolverInterface.php b/src/Approvals/PendingActionResolverInterface.php index b24369e..d7a11ec 100644 --- a/src/Approvals/PendingActionResolverInterface.php +++ b/src/Approvals/PendingActionResolverInterface.php @@ -14,14 +14,16 @@ interface PendingActionResolverInterface { /** * Resolve a pending action by identifier. * - * Implementations own lookup and persistence. Callers own authentication and - * authorization before invoking the resolver. + * Implementations own lookup, handler permission checks, handler dispatch, and + * resolution audit persistence. Callers own authentication before invoking the + * resolver. * * @param string $pending_action_id Stable pending-action identifier. * @param ApprovalDecision $decision Accepted/rejected decision. + * @param string $resolver Resolver identifier, such as a user, token, or service actor. * @param array $payload Fresh resolver payload supplied with the decision. * @param array $context Optional caller context. * @return mixed Generic resolver result. */ - public function resolve_pending_action( string $pending_action_id, ApprovalDecision $decision, array $payload = array(), array $context = array() ): mixed; + public function resolve_pending_action( string $pending_action_id, ApprovalDecision $decision, string $resolver, array $payload = array(), array $context = array() ): mixed; } diff --git a/src/Approvals/PendingActionStatus.php b/src/Approvals/PendingActionStatus.php new file mode 100644 index 0000000..3ae1908 --- /dev/null +++ b/src/Approvals/PendingActionStatus.php @@ -0,0 +1,57 @@ + + */ + public static function values(): array { + return array( self::PENDING, self::ACCEPTED, self::REJECTED, self::EXPIRED, self::DELETED ); + } + + /** + * Whether a status is part of the canonical vocabulary. + */ + public static function is_valid( string $status ): bool { + return in_array( $status, self::values(), true ); + } + + /** + * Normalize a status string or throw when it is not supported. + */ + public static function normalize( string $status ): string { + $status = trim( $status ); + if ( ! self::is_valid( $status ) ) { + throw new \InvalidArgumentException( 'invalid_ai_pending_action_status: status must be pending, accepted, rejected, expired, or deleted' ); + } + + return $status; + } + + /** + * Whether the status is terminal for audit purposes. + */ + public static function is_terminal( string $status ): bool { + return in_array( self::normalize( $status ), array( self::ACCEPTED, self::REJECTED, self::EXPIRED, self::DELETED ), true ); + } +} diff --git a/src/Approvals/PendingActionStoreInterface.php b/src/Approvals/PendingActionStoreInterface.php index fe16a53..fe049e9 100644 --- a/src/Approvals/PendingActionStoreInterface.php +++ b/src/Approvals/PendingActionStoreInterface.php @@ -22,26 +22,68 @@ interface PendingActionStoreInterface { /** - * Persist a pending action payload under a caller-provided action ID. + * Persist a pending action record. * - * @param string $action_id Durable action identifier. - * @param array $payload JSON-serializable pending action payload. + * @param PendingAction $action Durable pending action record. * @return bool Whether the payload was stored successfully. */ - public function store( string $action_id, array $payload ): bool; + public function store( PendingAction $action ): bool; /** - * Retrieve a pending action payload by action ID. + * Retrieve a pending action by action ID. * - * @param string $action_id Durable action identifier. - * @return array|null Pending action payload, or null when not found. + * @param string $action_id Durable action identifier. + * @param bool $include_resolved Whether terminal audit rows may be returned. + * @return PendingAction|null Pending action, or null when not found. + */ + public function get( string $action_id, bool $include_resolved = false ): ?PendingAction; + + /** + * List durable pending action records for queue and audit surfaces. + * + * Supported filters are implementation-defined, but SHOULD include status, + * kind, workspace, agent, creator, resolver, created/resolved date ranges, + * limit, and offset when the backing store can express them. + * + * @param array $filters Query filters. + * @return array + */ + public function list( array $filters = array() ): array; + + /** + * Summarize durable pending action records for operator inspection. + * + * @param array $filters Query filters. + * @return array + */ + public function summary( array $filters = array() ): array; + + /** + * Record a terminal resolution while retaining the action for audit. + * + * @param string $action_id Durable action identifier. + * @param ApprovalDecision $decision Accepted/rejected decision. + * @param string $resolver Resolver identifier, such as a user, token, or service actor. + * @param mixed|null $result JSON-serializable resolution result. + * @param string|null $error Human-readable resolution error. + * @param array $metadata JSON-serializable resolution metadata. + * @return bool Whether the audit update completed successfully. + */ + public function record_resolution( string $action_id, ApprovalDecision $decision, string $resolver, $result = null, ?string $error = null, array $metadata = array() ): bool; + + /** + * Mark due pending actions as expired. + * + * @param string|null $before Timestamp boundary; defaults to implementation time. + * @return int Number of actions expired. */ - public function get( string $action_id ): ?array; + public function expire( ?string $before = null ): int; /** - * Delete a pending action payload by action ID. + * Delete a pending action by action ID. * - * Implementations SHOULD make delete idempotent for missing action IDs. + * Implementations SHOULD retain an audit row with `deleted` status when the + * backing store supports durable audit history. * * @param string $action_id Durable action identifier. * @return bool Whether the delete operation completed successfully. diff --git a/tests/approval-action-value-shape-smoke.php b/tests/approval-action-value-shape-smoke.php index 4cf5e22..a6096f2 100644 --- a/tests/approval-action-value-shape-smoke.php +++ b/tests/approval-action-value-shape-smoke.php @@ -21,25 +21,32 @@ echo "\n[1] Array input round-trips through the pending action shape:\n"; $raw = array( - 'action_id' => 'approve-123', - 'kind' => 'content_update', - 'summary' => 'Update the public content summary.', - 'preview' => array( + 'action_id' => 'approve-123', + 'kind' => 'content_update', + 'summary' => 'Update the public content summary.', + 'preview' => array( 'before' => 'Old summary', 'after' => 'New summary', ), - 'apply_input' => array( + 'apply_input' => array( 'target_id' => 'content-42', 'changes' => array( 'summary' => 'New summary' ), ), - 'created_by' => 'user-7', - 'agent_id' => 'agent-reviewer', - 'context' => array( + 'workspace' => 'workspace-1', + 'agent' => 'agent-reviewer', + 'creator' => 'user-7', + 'status' => AgentsAPI\AI\Approvals\PendingActionStatus::PENDING, + 'created_at' => '2026-05-03T15:00:00Z', + 'expires_at' => '2026-05-04T15:00:00Z', + 'resolved_at' => null, + 'resolver' => null, + 'resolution_result' => null, + 'resolution_error' => null, + 'resolution_metadata' => array(), + 'metadata' => array( 'source' => 'runtime', 'trace' => array( 'request_id' => 'req-abc' ), ), - 'created_at' => '2026-05-03T15:00:00Z', - 'expires_at' => '2026-05-04T15:00:00Z', ); $action = AgentsAPI\AI\Approvals\PendingAction::from_array( $raw ); @@ -48,7 +55,11 @@ agents_api_smoke_assert_equals( $raw, $array, 'canonical array preserves all public fields', $failures, $passes ); agents_api_smoke_assert_equals( 'approve-123', $action->get_action_id(), 'action id getter returns canonical id', $failures, $passes ); agents_api_smoke_assert_equals( 'content_update', $action->get_kind(), 'kind getter returns generic kind', $failures, $passes ); -agents_api_smoke_assert_equals( array( 'source' => 'runtime', 'trace' => array( 'request_id' => 'req-abc' ) ), $action->get_context(), 'context getter returns context', $failures, $passes ); +agents_api_smoke_assert_equals( 'workspace-1', $action->get_workspace(), 'workspace getter returns generic workspace', $failures, $passes ); +agents_api_smoke_assert_equals( 'agent-reviewer', $action->get_agent(), 'agent getter returns generic agent', $failures, $passes ); +agents_api_smoke_assert_equals( 'user-7', $action->get_creator(), 'creator getter returns generic creator', $failures, $passes ); +agents_api_smoke_assert_equals( AgentsAPI\AI\Approvals\PendingActionStatus::PENDING, $action->get_status(), 'status getter returns canonical status', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'source' => 'runtime', 'trace' => array( 'request_id' => 'req-abc' ) ), $action->get_metadata(), 'metadata getter returns metadata', $failures, $passes ); echo "\n[2] Optional identity and expiration fields can be absent:\n"; $minimal = AgentsAPI\AI\Approvals\PendingAction::from_array( @@ -62,19 +73,53 @@ ) )->to_array(); -agents_api_smoke_assert_equals( null, $minimal['created_by'], 'created_by defaults to null', $failures, $passes ); -agents_api_smoke_assert_equals( null, $minimal['agent_id'], 'agent_id defaults to null', $failures, $passes ); -agents_api_smoke_assert_equals( array(), $minimal['context'], 'context defaults to empty array', $failures, $passes ); +agents_api_smoke_assert_equals( null, $minimal['workspace'], 'workspace defaults to null', $failures, $passes ); +agents_api_smoke_assert_equals( null, $minimal['agent'], 'agent defaults to null', $failures, $passes ); +agents_api_smoke_assert_equals( null, $minimal['creator'], 'creator defaults to null', $failures, $passes ); +agents_api_smoke_assert_equals( AgentsAPI\AI\Approvals\PendingActionStatus::PENDING, $minimal['status'], 'status defaults to pending', $failures, $passes ); +agents_api_smoke_assert_equals( null, $minimal['resolved_at'], 'resolved_at defaults to null', $failures, $passes ); +agents_api_smoke_assert_equals( null, $minimal['resolver'], 'resolver defaults to null', $failures, $passes ); +agents_api_smoke_assert_equals( null, $minimal['resolution_result'], 'resolution_result defaults to null', $failures, $passes ); +agents_api_smoke_assert_equals( null, $minimal['resolution_error'], 'resolution_error defaults to null', $failures, $passes ); +agents_api_smoke_assert_equals( array(), $minimal['resolution_metadata'], 'resolution_metadata defaults to empty array', $failures, $passes ); +agents_api_smoke_assert_equals( array(), $minimal['metadata'], 'metadata defaults to empty array', $failures, $passes ); agents_api_smoke_assert_equals( null, $minimal['expires_at'], 'expires_at defaults to null', $failures, $passes ); -echo "\n[3] Invalid fields are rejected generically:\n"; +echo "\n[3] Terminal audit fields are preserved:\n"; +$resolved = AgentsAPI\AI\Approvals\PendingAction::from_array( + array( + 'action_id' => 'approve-125', + 'kind' => 'file_patch', + 'summary' => 'Apply a patch.', + 'preview' => array( 'diff' => '...' ), + 'apply_input' => array( 'patch' => '...' ), + 'workspace' => 'workspace-1', + 'agent' => 'agent-reviewer', + 'creator' => 'user-7', + 'status' => AgentsAPI\AI\Approvals\PendingActionStatus::ACCEPTED, + 'created_at' => '2026-05-03T15:10:00Z', + 'resolved_at' => '2026-05-03T15:12:00Z', + 'resolver' => 'user-9', + 'resolution_result' => array( 'applied' => true ), + 'resolution_metadata' => array( 'request_id' => 'req-xyz' ), + ) +)->to_array(); + +agents_api_smoke_assert_equals( AgentsAPI\AI\Approvals\PendingActionStatus::ACCEPTED, $resolved['status'], 'terminal status is preserved', $failures, $passes ); +agents_api_smoke_assert_equals( 'user-9', $resolved['resolver'], 'resolver audit field is preserved', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'applied' => true ), $resolved['resolution_result'], 'resolution result is preserved', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'request_id' => 'req-xyz' ), $resolved['resolution_metadata'], 'resolution metadata is preserved', $failures, $passes ); + +echo "\n[4] Invalid fields are rejected generically:\n"; $invalid_cases = array( 'missing action_id' => array( 'kind' => 'update', 'summary' => 'Summary', 'preview' => array(), 'apply_input' => array(), 'created_at' => 'now' ), 'invalid action_id' => array( 'action_id' => 123, 'kind' => 'update', 'summary' => 'Summary', 'preview' => array(), 'apply_input' => array(), 'created_at' => 'now' ), 'empty kind' => array( 'action_id' => 'approve-1', 'kind' => '', 'summary' => 'Summary', 'preview' => array(), 'apply_input' => array(), 'created_at' => 'now' ), - 'invalid context' => array( 'action_id' => 'approve-1', 'kind' => 'update', 'summary' => 'Summary', 'preview' => array(), 'apply_input' => array(), 'context' => 'nope', 'created_at' => 'now' ), + 'invalid metadata' => array( 'action_id' => 'approve-1', 'kind' => 'update', 'summary' => 'Summary', 'preview' => array(), 'apply_input' => array(), 'metadata' => 'nope', 'created_at' => 'now' ), + 'invalid status' => array( 'action_id' => 'approve-1', 'kind' => 'update', 'summary' => 'Summary', 'preview' => array(), 'apply_input' => array(), 'status' => 'approved', 'created_at' => 'now' ), 'non-serializable input' => array( 'action_id' => 'approve-1', 'kind' => 'update', 'summary' => 'Summary', 'preview' => array(), 'apply_input' => tmpfile(), 'created_at' => 'now' ), - 'invalid optional string' => array( 'action_id' => 'approve-1', 'kind' => 'update', 'summary' => 'Summary', 'preview' => array(), 'apply_input' => array(), 'created_by' => '', 'created_at' => 'now' ), + 'invalid optional string' => array( 'action_id' => 'approve-1', 'kind' => 'update', 'summary' => 'Summary', 'preview' => array(), 'apply_input' => array(), 'creator' => '', 'created_at' => 'now' ), + 'terminal missing audit' => array( 'action_id' => 'approve-1', 'kind' => 'update', 'summary' => 'Summary', 'preview' => array(), 'apply_input' => array(), 'status' => 'accepted', 'created_at' => 'now' ), ); foreach ( $invalid_cases as $name => $invalid_action ) { diff --git a/tests/approval-resolver-contract-smoke.php b/tests/approval-resolver-contract-smoke.php index 9d9ad3e..72ada70 100644 --- a/tests/approval-resolver-contract-smoke.php +++ b/tests/approval-resolver-contract-smoke.php @@ -29,52 +29,94 @@ agents_api_smoke_assert_equals( false, $accepted->is_rejected(), 'accepted decision does not report rejected', $failures, $passes ); agents_api_smoke_assert_equals( 'rejected', (string) $rejected, 'rejected decision stringifies to normalized value', $failures, $passes ); agents_api_smoke_assert_equals( true, $rejected->is_rejected(), 'rejected decision reports rejected', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'pending', 'accepted', 'rejected', 'expired', 'deleted' ), AgentsAPI\AI\Approvals\PendingActionStatus::values(), 'status vocabulary is stable', $failures, $passes ); +agents_api_smoke_assert_equals( true, AgentsAPI\AI\Approvals\PendingActionStatus::is_terminal( AgentsAPI\AI\Approvals\PendingActionStatus::ACCEPTED ), 'accepted status is terminal', $failures, $passes ); + +$action = AgentsAPI\AI\Approvals\PendingAction::from_array( + array( + 'action_id' => 'diff-123', + 'kind' => 'file_patch', + 'summary' => 'Apply a patch.', + 'preview' => array( 'diff' => '...' ), + 'apply_input' => array( 'target' => 'diff-123' ), + 'workspace' => 'workspace-main', + 'agent' => 'agent-reviewer', + 'creator' => 'user-7', + 'created_at' => '2026-05-03T15:00:00Z', + ) +); $handler = new class() implements AgentsAPI\AI\Approvals\PendingActionHandlerInterface { - public function handle_pending_action( AgentsAPI\AI\Approvals\ApprovalDecision $decision, array $apply_input, array $payload = array(), array $context = array() ): mixed { + public function can_resolve_pending_action( AgentsAPI\AI\Approvals\PendingAction $action, AgentsAPI\AI\Approvals\ApprovalDecision $decision, array $payload = array(), array $context = array() ): bool { + return 'blocked' !== ( $payload['reason'] ?? null ) && 'workspace-main' === $action->get_workspace(); + } + + public function handle_pending_action( AgentsAPI\AI\Approvals\PendingAction $action, AgentsAPI\AI\Approvals\ApprovalDecision $decision, array $payload = array(), array $context = array() ): mixed { return array( - 'decision' => $decision->value(), - 'target' => $apply_input['target'] ?? null, - 'reason' => $payload['reason'] ?? null, - 'actor' => $context['actor'] ?? null, + 'decision' => $decision->value(), + 'target' => $action->get_apply_input()['target'] ?? null, + 'workspace' => $action->get_workspace(), + 'reason' => $payload['reason'] ?? null, + 'actor' => $context['actor'] ?? null, ); } }; $handled = $handler->handle_pending_action( + $action, $accepted, - array( 'target' => 'diff-123' ), array( 'reason' => 'looks-good' ), array( 'actor' => 'reviewer' ) ); +agents_api_smoke_assert_equals( true, $handler->can_resolve_pending_action( $action, $accepted, array( 'reason' => 'looks-good' ) ), 'handler-level permission check can allow resolution', $failures, $passes ); +agents_api_smoke_assert_equals( false, $handler->can_resolve_pending_action( $action, $accepted, array( 'reason' => 'blocked' ) ), 'handler-level permission check can deny resolution', $failures, $passes ); agents_api_smoke_assert_equals( 'accepted', $handled['decision'], 'handler receives decision object', $failures, $passes ); -agents_api_smoke_assert_equals( 'diff-123', $handled['target'], 'handler receives stored apply input', $failures, $passes ); +agents_api_smoke_assert_equals( 'diff-123', $handled['target'], 'handler receives stored action apply input', $failures, $passes ); +agents_api_smoke_assert_equals( 'workspace-main', $handled['workspace'], 'handler receives stored workspace', $failures, $passes ); agents_api_smoke_assert_equals( 'looks-good', $handled['reason'], 'handler receives resolver payload', $failures, $passes ); agents_api_smoke_assert_equals( 'reviewer', $handled['actor'], 'handler receives optional context', $failures, $passes ); $resolver = new class( $handler ) implements AgentsAPI\AI\Approvals\PendingActionResolverInterface { public function __construct( private AgentsAPI\AI\Approvals\PendingActionHandlerInterface $handler ) {} - public function resolve_pending_action( string $pending_action_id, AgentsAPI\AI\Approvals\ApprovalDecision $decision, array $payload = array(), array $context = array() ): mixed { + public function resolve_pending_action( string $pending_action_id, AgentsAPI\AI\Approvals\ApprovalDecision $decision, string $resolver, array $payload = array(), array $context = array() ): mixed { + $action = AgentsAPI\AI\Approvals\PendingAction::from_array( + array( + 'action_id' => $pending_action_id, + 'kind' => 'file_patch', + 'summary' => 'Apply a patch.', + 'preview' => array( 'diff' => '...' ), + 'apply_input' => array( 'target' => $pending_action_id ), + 'workspace' => 'workspace-main', + 'created_at' => '2026-05-03T15:00:00Z', + ) + ); + + if ( ! $this->handler->can_resolve_pending_action( $action, $decision, $payload, $context ) ) { + return array( 'success' => false, 'resolver' => $resolver ); + } + return $this->handler->handle_pending_action( + $action, $decision, - array( 'target' => $pending_action_id ), $payload, $context - ); + ) + array( 'resolver' => $resolver ); } }; $resolved = $resolver->resolve_pending_action( 'diff-456', AgentsAPI\AI\Approvals\ApprovalDecision::rejected(), + 'user-9', array( 'reason' => 'needs-work' ) ); agents_api_smoke_assert_equals( 'rejected', $resolved['decision'], 'resolver receives rejected decision', $failures, $passes ); agents_api_smoke_assert_equals( 'diff-456', $resolved['target'], 'resolver maps pending action id to stored input', $failures, $passes ); agents_api_smoke_assert_equals( 'needs-work', $resolved['reason'], 'resolver forwards payload to handler', $failures, $passes ); +agents_api_smoke_assert_equals( 'user-9', $resolved['resolver'], 'resolver receives resolver audit identity', $failures, $passes ); try { AgentsAPI\AI\Approvals\ApprovalDecision::from_string( 'approved' ); diff --git a/tests/bootstrap-smoke.php b/tests/bootstrap-smoke.php index 7f10fab..1602597 100644 --- a/tests/bootstrap-smoke.php +++ b/tests/bootstrap-smoke.php @@ -67,6 +67,7 @@ agents_api_smoke_assert_equals( true, class_exists( 'AgentsAPI\\AI\\AgentMarkdownSectionCompactionAdapter' ), 'AgentsAPI\\AI\\AgentMarkdownSectionCompactionAdapter contract is available', $failures, $passes ); agents_api_smoke_assert_equals( true, class_exists( 'AgentsAPI\\AI\\AgentConversationLoop' ), 'AgentConversationLoop facade is available', $failures, $passes ); agents_api_smoke_assert_equals( true, interface_exists( 'AgentsAPI\\AI\\Approvals\\PendingActionStoreInterface' ), 'AgentsAPI\\AI\\Approvals\\PendingActionStoreInterface contract is available', $failures, $passes ); +agents_api_smoke_assert_equals( true, class_exists( 'AgentsAPI\\AI\\Approvals\\PendingActionStatus' ), 'AgentsAPI\\AI\\Approvals\\PendingActionStatus vocabulary is available', $failures, $passes ); foreach ( $namespace_map as $legacy_class => $target_class ) { agents_api_smoke_assert_equals( true, class_exists( $target_class ) || interface_exists( $target_class ), $target_class . ' contract is available', $failures, $passes ); agents_api_smoke_assert_equals( false, class_exists( $legacy_class, false ) || interface_exists( $legacy_class, false ), $legacy_class . ' compatibility alias is not loaded', $failures, $passes ); diff --git a/tests/pending-action-store-contract-smoke.php b/tests/pending-action-store-contract-smoke.php index 05a2556..3fe8657 100644 --- a/tests/pending-action-store-contract-smoke.php +++ b/tests/pending-action-store-contract-smoke.php @@ -28,20 +28,37 @@ $methods[ $method->getName() ] = $method; } -echo "\n[2] Pending action store exposes the minimal generic lifecycle:\n"; -agents_api_smoke_assert_equals( array( 'store', 'get', 'delete' ), array_keys( $methods ), 'contract exposes only store/get/delete', $failures, $passes ); +echo "\n[2] Pending action store exposes the generic durable lifecycle and audit surface:\n"; +agents_api_smoke_assert_equals( array( 'store', 'get', 'list', 'summary', 'record_resolution', 'expire', 'delete' ), array_keys( $methods ), 'contract exposes durable lifecycle methods', $failures, $passes ); agents_api_smoke_assert_equals( 'bool', (string) $methods['store']->getReturnType(), 'store returns bool', $failures, $passes ); -agents_api_smoke_assert_equals( '?array', (string) $methods['get']->getReturnType(), 'get returns nullable array', $failures, $passes ); +agents_api_smoke_assert_equals( '?AgentsAPI\\AI\\Approvals\\PendingAction', (string) $methods['get']->getReturnType(), 'get returns nullable pending action', $failures, $passes ); +agents_api_smoke_assert_equals( 'array', (string) $methods['list']->getReturnType(), 'list returns array', $failures, $passes ); +agents_api_smoke_assert_equals( 'array', (string) $methods['summary']->getReturnType(), 'summary returns array', $failures, $passes ); +agents_api_smoke_assert_equals( 'bool', (string) $methods['record_resolution']->getReturnType(), 'record_resolution returns bool', $failures, $passes ); +agents_api_smoke_assert_equals( 'int', (string) $methods['expire']->getReturnType(), 'expire returns int', $failures, $passes ); agents_api_smoke_assert_equals( 'bool', (string) $methods['delete']->getReturnType(), 'delete returns bool', $failures, $passes ); -$store_parameters = $methods['store']->getParameters(); -$get_parameters = $methods['get']->getParameters(); -$delete_parameters = $methods['delete']->getParameters(); -agents_api_smoke_assert_equals( array( 'action_id', 'payload' ), array_map( static fn( ReflectionParameter $parameter ): string => $parameter->getName(), $store_parameters ), 'store accepts action ID and payload', $failures, $passes ); -agents_api_smoke_assert_equals( 'string', (string) $store_parameters[0]->getType(), 'store action ID is string', $failures, $passes ); -agents_api_smoke_assert_equals( 'array', (string) $store_parameters[1]->getType(), 'store payload is array', $failures, $passes ); -agents_api_smoke_assert_equals( array( 'action_id' ), array_map( static fn( ReflectionParameter $parameter ): string => $parameter->getName(), $get_parameters ), 'get accepts action ID only', $failures, $passes ); +$store_parameters = $methods['store']->getParameters(); +$get_parameters = $methods['get']->getParameters(); +$list_parameters = $methods['list']->getParameters(); +$summary_parameters = $methods['summary']->getParameters(); +$resolution_parameters = $methods['record_resolution']->getParameters(); +$expire_parameters = $methods['expire']->getParameters(); +$delete_parameters = $methods['delete']->getParameters(); +agents_api_smoke_assert_equals( array( 'action' ), array_map( static fn( ReflectionParameter $parameter ): string => $parameter->getName(), $store_parameters ), 'store accepts pending action value object', $failures, $passes ); +agents_api_smoke_assert_equals( 'AgentsAPI\\AI\\Approvals\\PendingAction', (string) $store_parameters[0]->getType(), 'store action is pending action', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'action_id', 'include_resolved' ), array_map( static fn( ReflectionParameter $parameter ): string => $parameter->getName(), $get_parameters ), 'get accepts action ID and audit flag', $failures, $passes ); agents_api_smoke_assert_equals( 'string', (string) $get_parameters[0]->getType(), 'get action ID is string', $failures, $passes ); +agents_api_smoke_assert_equals( 'bool', (string) $get_parameters[1]->getType(), 'get include_resolved is bool', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'filters' ), array_map( static fn( ReflectionParameter $parameter ): string => $parameter->getName(), $list_parameters ), 'list accepts filters', $failures, $passes ); +agents_api_smoke_assert_equals( 'array', (string) $list_parameters[0]->getType(), 'list filters are array', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'filters' ), array_map( static fn( ReflectionParameter $parameter ): string => $parameter->getName(), $summary_parameters ), 'summary accepts filters', $failures, $passes ); +agents_api_smoke_assert_equals( 'array', (string) $summary_parameters[0]->getType(), 'summary filters are array', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'action_id', 'decision', 'resolver', 'result', 'error', 'metadata' ), array_map( static fn( ReflectionParameter $parameter ): string => $parameter->getName(), $resolution_parameters ), 'record_resolution accepts audit fields', $failures, $passes ); +agents_api_smoke_assert_equals( 'AgentsAPI\\AI\\Approvals\\ApprovalDecision', (string) $resolution_parameters[1]->getType(), 'record_resolution decision is approval decision', $failures, $passes ); +agents_api_smoke_assert_equals( 'string', (string) $resolution_parameters[2]->getType(), 'record_resolution resolver is string', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'before' ), array_map( static fn( ReflectionParameter $parameter ): string => $parameter->getName(), $expire_parameters ), 'expire accepts optional boundary', $failures, $passes ); +agents_api_smoke_assert_equals( '?string', (string) $expire_parameters[0]->getType(), 'expire boundary is nullable string', $failures, $passes ); agents_api_smoke_assert_equals( array( 'action_id' ), array_map( static fn( ReflectionParameter $parameter ): string => $parameter->getName(), $delete_parameters ), 'delete accepts action ID only', $failures, $passes ); agents_api_smoke_assert_equals( 'string', (string) $delete_parameters[0]->getType(), 'delete action ID is string', $failures, $passes );