From 6b55d78360b52b223ccf11e9dae31f23d89cfc20 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 6 Jul 2026 14:51:39 -0700 Subject: [PATCH 1/5] fix(reborn): converge harness turn-state so real gate dispatch is testable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #5722: RebornIntegrationGroup's real runs live in a turn-state store disjoint from the harness's own local-dev composition, so the interaction services built from local_dev_{approval,auth}_interaction_service_for_test could never find the group's runs (WorkflowRejected{ScopeNotFound}). Adds a TurnRunSnapshotSource seam so the turn-run locator can read from a caller-supplied store instead of always deriving it from local_runtime.turn_state; production callers are unaffected (same value, now behind a trait object). Wires this into RebornIntegrationGroup via a new opt-in with_real_gate_dispatch_services() flag, and adds submit_approval_resolution/submit_auth_resolution so tests can drive the literal submit_inbound dispatch arm instead of the harness's direct TurnCoordinator::resume_turn shortcut. Also adds an integration-tier proof that build_triggered_run_delivery_hook assembles a working driver over a real local-dev RebornRuntime and records outcomes through the caller-supplied TriggeredRunDeliveryStore — the factory-construction path crate-tier tests don't cover (they construct the driver directly). Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + Cargo.toml | 8 ++ .../src/runtime.rs | 107 +++++++++------ .../src/runtime/auth_interaction.rs | 43 ++---- .../src/runtime/test_support.rs | 69 ++++++++++ .../src/runtime/turn_run_snapshot.rs | 53 ++++++++ tests/integration/group_approvals/main.rs | 28 ++++ ...ario_submit_inbound_approval_resolution.rs | 88 +++++++++++++ tests/integration/support/builder.rs | 44 +++++++ tests/integration/support/group.rs | 54 +++++++- tests/integration/support/group_options.rs | 15 +++ tests/integration/support/harness/mod.rs | 24 ++++ .../support/harness/profiles/core_builtin.rs | 1 + .../support/harness/profiles/github.rs | 1 + .../support/harness/profiles/mock_mcp.rs | 1 + .../support/harness/profiles/qa_smoke.rs | 1 + .../support/harness/profiles/web_access.rs | 1 + tests/integration/support/test_adapter.rs | 68 +++++++++- .../integration/triggered_delivery_outcome.rs | 123 ++++++++++++++++++ 19 files changed, 661 insertions(+), 69 deletions(-) create mode 100644 crates/ironclaw_reborn_composition/src/runtime/turn_run_snapshot.rs create mode 100644 tests/integration/group_approvals/scenario_submit_inbound_approval_resolution.rs create mode 100644 tests/integration/triggered_delivery_outcome.rs diff --git a/Cargo.lock b/Cargo.lock index 1bfc17bea93..25776a382f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3941,6 +3941,7 @@ dependencies = [ "ironclaw_memory", "ironclaw_network", "ironclaw_oauth", + "ironclaw_outbound", "ironclaw_processes", "ironclaw_product_adapters", "ironclaw_product_workflow", diff --git a/Cargo.toml b/Cargo.toml index bace9d419e3..f77e9e88a04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -327,6 +327,10 @@ ironclaw_triggers = { path = "crates/ironclaw_triggers", version = "0.1.0", feat ironclaw_conversations = { path = "crates/ironclaw_conversations", version = "0.1.0" } ironclaw_trust = { path = "crates/ironclaw_trust", version = "0.1.0" } ironclaw_wasm = { path = "crates/ironclaw_wasm", version = "0.1.0" } +# `InMemoryTriggeredRunDeliveryStore` for the triggered-delivery outcome-seam +# int-tier proof (asserting a real `TriggeredRunDeliveryDriver`'s recorded +# outcome via the same public store the composition factory accepts). +ironclaw_outbound = { path = "crates/ironclaw_outbound", version = "0.1.0" } # Same idea for embeddings: `MockEmbeddings` is only reachable in dev builds. ironclaw_embeddings = { path = "crates/ironclaw_embeddings", version = "0.1.0", features = ["testing"] } # Named `#[case]` parametrization for the storage-backend matrix tests (slice 3). @@ -415,6 +419,10 @@ name = "reborn_integration_oauth_refresh" path = "tests/integration/oauth_refresh.rs" required-features = ["libsql"] +[[test]] +name = "reborn_integration_triggered_delivery_outcome" +path = "tests/integration/triggered_delivery_outcome.rs" + [[test]] name = "reborn_integration_attach" path = "tests/integration/attach.rs" diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index 8353e7133e0..6d6127d5745 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -103,6 +103,7 @@ use ironclaw_turns::run_profile::UserProfileContext; use self::latency::{trace_runtime_latency_error, trace_runtime_latency_ok}; use self::runtime_turn_scheduler::RuntimeTurnScheduler; +pub(crate) use self::turn_run_snapshot::TurnRunSnapshotSource; use crate::default_system_prompt::DefaultSystemPromptIdentitySource; use crate::factory::{LocalDevRootFilesystem, LocalDevTurnStateStore, builtin_extension_registry}; use crate::local_dev_capability_policy::{LocalDevCapabilityPolicy, local_dev_capability_policy}; @@ -355,6 +356,7 @@ mod skills; #[cfg(feature = "test-support")] #[path = "runtime/test_support.rs"] mod test_support; +mod turn_run_snapshot; #[cfg(feature = "test-support")] pub(crate) use local_dev::PROJECT_CREATE_CAPABILITY_ID; @@ -579,16 +581,43 @@ impl RegistryPersistentApprovalGranteeResolver { /// so the two never drift (W5-WEBUI-API-2 follow-up). `audit_sink` is `None` from the /// test accessor: production wires one for audit-log observability only, not /// correctness the test needs. Propagates policy/resolver construction failures -/// instead of collapsing them to `None`. +/// instead of collapsing them to `None`. Thin wrapper over +/// `build_local_dev_approval_interaction_service_with_turn_run_source` using +/// `local_runtime.turn_state` as the turn-run snapshot source — production +/// behavior is unchanged by the seam below. pub(crate) fn build_local_dev_approval_interaction_service( local_runtime: &crate::factory::RebornLocalRuntimeServices, local_dev_capability_policy: Arc, turn_coordinator: Arc, audit_sink: Option>, ) -> Result, RebornRuntimeError> { - let approval_turn_runs = Arc::new(LocalDevApprovalTurnRunLocator::new(Arc::clone( - &local_runtime.turn_state, - ))); + build_local_dev_approval_interaction_service_with_turn_run_source( + local_runtime, + local_dev_capability_policy, + turn_coordinator, + audit_sink, + Arc::clone(&local_runtime.turn_state) as Arc, + ) +} + +/// Identical to [`build_local_dev_approval_interaction_service`] +/// except the approval turn-run locator reads `turn_run_source` instead of +/// always deriving it from `local_runtime.turn_state`. Lets a caller whose +/// real runs live in a DIFFERENT `TurnStateStore` composition (e.g. +/// `RebornIntegrationGroup`'s own `build_default_planned_runtime`, whose runs +/// are invisible to this crate's `local_runtime.turn_state`) substitute its +/// own store. `build_local_dev_approval_interaction_service` is the +/// production entry point and is a thin wrapper over this function with +/// `local_runtime.turn_state` as the source, so production behavior is +/// unchanged. +pub(crate) fn build_local_dev_approval_interaction_service_with_turn_run_source( + local_runtime: &crate::factory::RebornLocalRuntimeServices, + local_dev_capability_policy: Arc, + turn_coordinator: Arc, + audit_sink: Option>, + turn_run_source: Arc, +) -> Result, RebornRuntimeError> { + let approval_turn_runs = Arc::new(LocalDevApprovalTurnRunLocator::new(turn_run_source)); let approval_read_model = Arc::new(RunStateApprovalInteractionReadModel::new( local_runtime.approval_requests.clone(), approval_turn_runs, @@ -821,42 +850,28 @@ fn build_trigger_active_run_lookup( } struct LocalDevApprovalTurnRunLocator { - turn_state: Arc, + /// A trait object (not the concrete `LocalDevTurnStateStore`) so a + /// caller can substitute a different turn-state store's snapshot view — + /// see `turn_run_snapshot::TurnRunSnapshotSource` and + /// `build_local_dev_approval_interaction_service_with_turn_run_source`. + turn_state: Arc, } impl LocalDevApprovalTurnRunLocator { - fn new(turn_state: Arc) -> Self { + fn new(turn_state: Arc) -> Self { Self { turn_state } } async fn snapshot( &self, ) -> Result { - // Durable filesystem store: async `Result`; in-memory authority - // (no-DB builds or `inmemory-turn-state`): sync infallible. - #[cfg(all( - any(feature = "libsql", feature = "postgres"), - not(feature = "inmemory-turn-state") - ))] - { - self.turn_state - .persistence_snapshot() - .await - .map_err(|error| { - tracing::debug!( - %error, - "approval turn-run locator could not read turn persistence snapshot" - ); - approval_turn_locator_unavailable() - }) - } - #[cfg(any( - feature = "inmemory-turn-state", - not(any(feature = "libsql", feature = "postgres")) - ))] - { - Ok(self.turn_state.persistence_snapshot()) - } + self.turn_state.turn_run_snapshot().await.map_err(|error| { + tracing::debug!( + %error, + "approval turn-run locator could not read turn persistence snapshot" + ); + approval_turn_locator_unavailable() + }) } } @@ -1102,12 +1117,6 @@ fn snapshot_run_actor_matches( }) } -// Only referenced by the durable filesystem snapshot path (async `Result`); -// the in-memory authority's snapshot is infallible. -#[cfg(all( - any(feature = "libsql", feature = "postgres"), - not(feature = "inmemory-turn-state") -))] fn approval_turn_locator_unavailable() -> ironclaw_product_workflow::ProductWorkflowError { ironclaw_product_workflow::ProductWorkflowError::Transient { reason: "approval turn-run locator unavailable".to_string(), @@ -3775,10 +3784,32 @@ pub async fn build_reborn_runtime( }) } +/// Thin wrapper over +/// `build_webui_auth_interaction_service_with_turn_run_source` using +/// `turn_state_store` (production always passes `local_runtime.turn_state`) +/// as the turn-run snapshot source — production behavior is unchanged by the +/// seam below. fn build_webui_auth_interaction_service( product_auth: Option<&RebornProductAuthServices>, turn_state_store: Arc, turn_coordinator: Arc, +) -> Arc { + build_webui_auth_interaction_service_with_turn_run_source( + product_auth, + turn_state_store as Arc, + turn_coordinator, + ) +} + +/// Identical to [`build_webui_auth_interaction_service`] except +/// the auth read model reads `turn_run_source` instead of a hardcoded +/// `LocalDevTurnStateStore`. See +/// `build_local_dev_approval_interaction_service_with_turn_run_source`'s doc +/// for why this seam exists. +fn build_webui_auth_interaction_service_with_turn_run_source( + product_auth: Option<&RebornProductAuthServices>, + turn_run_source: Arc, + turn_coordinator: Arc, ) -> Arc { // `AuthFlowRecordSource` is optional on the product-auth bundle because // production may supply a durable read projection that is not the flow @@ -3793,7 +3824,7 @@ fn build_webui_auth_interaction_service( }; Arc::new(DefaultAuthInteractionService::new( Arc::new(auth_interaction::LocalDevAuthInteractionReadModel::new( - turn_state_store, + turn_run_source, flow_records, )), product_auth.flow_manager(), diff --git a/crates/ironclaw_reborn_composition/src/runtime/auth_interaction.rs b/crates/ironclaw_reborn_composition/src/runtime/auth_interaction.rs index da208edd32d..fb1b84986a6 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/auth_interaction.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/auth_interaction.rs @@ -13,7 +13,7 @@ use ironclaw_product_workflow::{ }; use ironclaw_turns::{GateRef, TurnPersistenceSnapshot, TurnRunId, TurnScope, TurnStatus}; -use crate::factory::LocalDevTurnStateStore; +use super::turn_run_snapshot::TurnRunSnapshotSource; #[derive(Debug, Clone, PartialEq, Eq)] struct BlockedAuthRun { @@ -22,7 +22,11 @@ struct BlockedAuthRun { } pub(super) struct LocalDevAuthInteractionReadModel { - turn_state: Arc, + /// A trait object (not the concrete `LocalDevTurnStateStore`) so a + /// caller can substitute a different turn-state store's snapshot view — + /// see `turn_run_snapshot::TurnRunSnapshotSource` and + /// `build_webui_auth_interaction_service_with_turn_run_source`. + turn_state: Arc, flow_records: Arc, } @@ -47,7 +51,7 @@ impl AuthInteractionService for UnavailableAuthInteractionService { impl LocalDevAuthInteractionReadModel { pub(super) fn new( - turn_state: Arc, + turn_state: Arc, flow_records: Arc, ) -> Self { Self { @@ -57,32 +61,13 @@ impl LocalDevAuthInteractionReadModel { } async fn snapshot(&self) -> Result { - // The durable filesystem store returns an async `Result`; the in-memory - // authority (no-DB builds, or any build with `inmemory-turn-state`) - // returns a sync infallible snapshot. - #[cfg(all( - any(feature = "libsql", feature = "postgres"), - not(feature = "inmemory-turn-state") - ))] - { - self.turn_state - .persistence_snapshot() - .await - .map_err(|error| { - tracing::debug!( - %error, - "auth interaction read model could not read turn persistence snapshot" - ); - auth_read_model_unavailable() - }) - } - #[cfg(any( - feature = "inmemory-turn-state", - not(any(feature = "libsql", feature = "postgres")) - ))] - { - Ok(self.turn_state.persistence_snapshot()) - } + self.turn_state.turn_run_snapshot().await.map_err(|error| { + tracing::debug!( + %error, + "auth interaction read model could not read turn persistence snapshot" + ); + auth_read_model_unavailable() + }) } async fn blocked_auth_runs( diff --git a/crates/ironclaw_reborn_composition/src/runtime/test_support.rs b/crates/ironclaw_reborn_composition/src/runtime/test_support.rs index 17249b27fff..108867593f0 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/test_support.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/test_support.rs @@ -59,4 +59,73 @@ impl RebornServices { turn_coordinator, )) } + + /// Like [`local_dev_approval_interaction_service_for_test`], but lets + /// the caller substitute the turn-run snapshot source the interaction + /// service's approval locator reads from — for harnesses whose real runs + /// live in a DIFFERENT `TurnStateStore` composition than this + /// `RebornServices`' own `local_runtime.turn_state` (e.g. + /// `RebornIntegrationGroup`, whose runs execute against its own + /// `shared.turn_store` via a separate `build_default_planned_runtime`). + /// Generic over `F` so any `FilesystemTurnStateStore`-backed store can be + /// passed directly, without this crate exposing `TurnRunSnapshotSource` + /// outside itself. + /// + /// For tests only -- gated behind `test-support`, ships zero bytes in + /// production builds. + /// + /// [`local_dev_approval_interaction_service_for_test`]: Self::local_dev_approval_interaction_service_for_test + #[cfg(feature = "test-support")] + pub fn local_dev_approval_interaction_service_with_turn_state_for_test( + &self, + turn_coordinator: Arc, + turn_state: Arc>, + ) -> Result>, RebornRuntimeError> + where + F: ironclaw_filesystem::RootFilesystem + Send + Sync + 'static, + { + let Some(local_runtime) = self.local_runtime.as_ref() else { + return Ok(None); + }; + let local_dev_capability_policy = + Arc::new(local_dev_capability_policy().map_err(|error| { + RebornRuntimeError::InvalidArgument { + reason: format!("local-dev capability policy is invalid: {error}"), + } + })?); + Ok(Some( + build_local_dev_approval_interaction_service_with_turn_run_source( + local_runtime, + local_dev_capability_policy, + turn_coordinator, + None, + turn_state as Arc, + )?, + )) + } + + /// Auth-side counterpart of + /// [`local_dev_approval_interaction_service_with_turn_state_for_test`]. See + /// that method's doc for why the turn-state override exists. + /// + /// For tests only -- gated behind `test-support`, ships zero bytes in + /// production builds. + /// + /// [`local_dev_approval_interaction_service_with_turn_state_for_test`]: Self::local_dev_approval_interaction_service_with_turn_state_for_test + #[cfg(feature = "test-support")] + pub fn local_dev_auth_interaction_service_with_turn_state_for_test( + &self, + turn_coordinator: Arc, + turn_state: Arc>, + ) -> Option> + where + F: ironclaw_filesystem::RootFilesystem + Send + Sync + 'static, + { + self.local_runtime.as_ref()?; + Some(build_webui_auth_interaction_service_with_turn_run_source( + self.product_auth.as_deref(), + turn_state as Arc, + turn_coordinator, + )) + } } diff --git a/crates/ironclaw_reborn_composition/src/runtime/turn_run_snapshot.rs b/crates/ironclaw_reborn_composition/src/runtime/turn_run_snapshot.rs new file mode 100644 index 00000000000..260c636eae8 --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/runtime/turn_run_snapshot.rs @@ -0,0 +1,53 @@ +//! Turn-run persistence-snapshot abstraction shared by the local-dev +//! approval/auth interaction locators (`LocalDevApprovalTurnRunLocator` in +//! `runtime.rs`, `LocalDevAuthInteractionReadModel` in +//! `runtime/auth_interaction.rs`). +//! +//! Exists so a `test-support` caller can substitute the turn-state store a +//! locator reads from without those locators depending on the specific +//! concrete `LocalDevTurnStateStore` type: `RebornIntegrationGroup`'s +//! real runs execute against its own `shared.turn_store` +//! (`FilesystemTurnStateStore`, built by +//! `build_default_planned_runtime`) — a DIFFERENT store than +//! `RebornServices.local_runtime.turn_state`, which is this crate's own +//! `build_reborn_services` composition. Production wiring is unaffected: +//! `build_reborn_runtime` still passes `Arc::clone(&local_runtime.turn_state)` +//! as the source, which implements this trait via the blanket impls below, so +//! its snapshot behavior is byte-identical to before this seam existed — this +//! module only replaces a hardcoded field type with a trait-object one. + +use async_trait::async_trait; +use ironclaw_turns::{TurnError, TurnPersistenceSnapshot}; + +#[async_trait] +pub(crate) trait TurnRunSnapshotSource: Send + Sync { + async fn turn_run_snapshot(&self) -> Result; +} + +// Durable filesystem store: async fallible snapshot. Generic over any +// `RootFilesystem` backend so both `LocalDevTurnStateStore` (production/ +// local-dev, when it resolves to `FilesystemTurnStateStore`) +// and a caller's own store (e.g. `RebornIntegrationGroup`'s +// `FilesystemTurnStateStore`) implement this identically. +// Unconditional (not cfg-gated on which backend `LocalDevTurnStateStore` +// happens to alias to in this build): `FilesystemTurnStateStore::persistence_snapshot` +// is always defined, and this impl targets a different concrete type per `F` +// than the `InMemoryTurnStateStore` impl below, so the two never conflict. +#[async_trait] +impl TurnRunSnapshotSource for ironclaw_turns::FilesystemTurnStateStore +where + F: ironclaw_filesystem::RootFilesystem + Send + Sync + 'static, +{ + async fn turn_run_snapshot(&self) -> Result { + self.persistence_snapshot().await + } +} + +// In-memory authority: sync infallible snapshot. Also unconditional, for the +// same reason as the impl above. +#[async_trait] +impl TurnRunSnapshotSource for ironclaw_turns::InMemoryTurnStateStore { + async fn turn_run_snapshot(&self) -> Result { + Ok(self.persistence_snapshot()) + } +} diff --git a/tests/integration/group_approvals/main.rs b/tests/integration/group_approvals/main.rs index dc25e15de31..6af0d6435fc 100644 --- a/tests/integration/group_approvals/main.rs +++ b/tests/integration/group_approvals/main.rs @@ -41,6 +41,7 @@ mod scenario_failure_category_demasked; mod scenario_gate_ref_edge_cases; mod scenario_gate_then_approve; mod scenario_gate_then_deny; +mod scenario_submit_inbound_approval_resolution; use reborn_support::builder::StorageMode; use reborn_support::group::{RebornIntegrationGroup, ScenarioReport}; @@ -95,6 +96,33 @@ async fn approvals_group_e2e() { report.assert_all_passed(); } +/// Proof-of-seam group — the harness mid-stack bypass that resolved +/// approval gates via `TurnCoordinator::resume_turn` directly, never through +/// `ApprovalInteractionService::resolve`, is removed for this group only. +/// `.with_real_gate_dispatch_services()` wires the REAL interaction services +/// over the group's own shared turn-state store, so +/// `submit_approval_resolution` reaches the literal `submit_inbound` dispatch +/// arm a real adapter's "approve"/"deny" reply hits. +#[tokio::test] +async fn approvals_group_real_gate_dispatch_e2e() { + let g = RebornIntegrationGroup::builder() + .with_real_gate_dispatch_services() + .live_approvals() + .await + .expect("group builds"); + + let mut report = ScenarioReport::new(); + report.record( + "submit_inbound_approval_resolution_approve", + scenario_submit_inbound_approval_resolution::approve(&g).await, + ); + report.record( + "submit_inbound_approval_resolution_deny", + scenario_submit_inbound_approval_resolution::deny(&g).await, + ); + report.assert_all_passed(); +} + #[tokio::test] async fn approvals_group_libsql_e2e() { let g = RebornIntegrationGroup::builder() diff --git a/tests/integration/group_approvals/scenario_submit_inbound_approval_resolution.rs b/tests/integration/group_approvals/scenario_submit_inbound_approval_resolution.rs new file mode 100644 index 00000000000..cc301d5564e --- /dev/null +++ b/tests/integration/group_approvals/scenario_submit_inbound_approval_resolution.rs @@ -0,0 +1,88 @@ +//! Scenario: a gated `builtin.write_file` call raises a real `BlockedApproval` +//! gate; resolving it via a REAL `submit_inbound(ApprovalResolution)` (the +//! literal dispatch arm a product adapter's "approve"/"deny" reply hits) +//! resumes the run — not `approve_gate`/`deny_gate`'s direct +//! `TurnCoordinator::resume_turn` shortcut. Requires the group to be built +//! with `.with_real_gate_dispatch_services()`, which wires the REAL +//! `ApprovalInteractionService` over the group's own shared turn-state store. +//! +//! Real path: scripted tool call → first-party runtime → `PermissionMode::Ask` +//! with auto-approve OFF → `TurnStatus::BlockedApproval` → real +//! `ApprovalInteractionService::resolve` (via `DefaultProductWorkflow::submit_inbound`) +//! → `coordinator.resume_turn` → the gated capability re-dispatches (approve) +//! or the run finalizes an authorization failure (deny). + +use super::reborn_support::group::{HarnessResult, RebornIntegrationGroup}; +use super::reborn_support::reply::RebornScriptedReply; +use ironclaw_product_adapters::{ApprovalDecision, ProductInboundAck}; +use ironclaw_turns::TurnStatus; +use serde_json::json; + +pub async fn approve(g: &RebornIntegrationGroup) -> HarnessResult<()> { + let h = g + .thread("conv-submit-inbound-approve") + .script([ + RebornScriptedReply::tool_call( + "builtin.write_file", + json!({"path": "/workspace/submit_inbound_approved.txt", "content": "approved via submit_inbound"}), + ), + RebornScriptedReply::text("file written after submit_inbound approval"), + ]) + .build() + .await?; + + let (run_id, gate_ref) = h + .submit_turn_until_blocked("write the submit_inbound approval file") + .await?; + + let ack = h + .submit_approval_resolution(&gate_ref, ApprovalDecision::ApproveOnce) + .await?; + if !matches!(ack, ProductInboundAck::Accepted { .. }) { + return Err( + format!("expected an Accepted ack for the real resolution, got {ack:?}").into(), + ); + } + h.wait_for_status(run_id, TurnStatus::Completed).await?; + + // Seam assertion (not `wait_for_status` alone): the approved write must + // have actually re-dispatched and persisted through the real capability + // path. + h.assert_workspace_file_contains("submit_inbound_approved.txt", "approved via submit_inbound") + .await?; + Ok(()) +} + +pub async fn deny(g: &RebornIntegrationGroup) -> HarnessResult<()> { + let h = g + .thread("conv-submit-inbound-deny") + .script([ + RebornScriptedReply::tool_call( + "builtin.write_file", + json!({"path": "/workspace/submit_inbound_denied.txt", "content": "should not persist"}), + ), + RebornScriptedReply::text("understood, the write was not authorized"), + ]) + .build() + .await?; + + let (run_id, gate_ref) = h + .submit_turn_until_blocked("write the submit_inbound denied file") + .await?; + + let ack = h + .submit_approval_resolution(&gate_ref, ApprovalDecision::Deny) + .await?; + if !matches!(ack, ProductInboundAck::Accepted { .. }) { + return Err( + format!("expected an Accepted ack for the real resolution, got {ack:?}").into(), + ); + } + h.wait_for_status(run_id, TurnStatus::Completed).await?; + + // Seam assertion: the denied capability must never have re-dispatched, so + // the file is absent on disk — not merely that the run reached Completed. + h.assert_workspace_file_absent("submit_inbound_denied.txt") + .await?; + Ok(()) +} diff --git a/tests/integration/support/builder.rs b/tests/integration/support/builder.rs index 547b83e58bc..51a778ef6ea 100644 --- a/tests/integration/support/builder.rs +++ b/tests/integration/support/builder.rs @@ -717,6 +717,50 @@ impl RebornIntegrationHarness { Ok((run_id, gate_ref)) } + /// Resolve a blocked approval gate via a REAL `submit_inbound(ApprovalResolution)` + /// — the dispatch arm a real adapter's "approve"/"deny" reply hits + /// (`ApprovalInteractionService::resolve`), unlike `approve_gate`/`deny_gate` + /// (which resume the coordinator directly, bypassing the interaction + /// service entirely). Only reaches a real resolution when the group was + /// built with `.with_real_gate_dispatch_services()` — otherwise the + /// workflow's default `RejectingApprovalInteractionService` rejects the + /// payload outright. + pub async fn submit_approval_resolution( + &self, + gate_ref: &GateRef, + decision: ironclaw_product_adapters::ApprovalDecision, + ) -> HarnessResult { + let event_id = format!("evt-{}", self.event_seq.fetch_add(1, Ordering::Relaxed)); + let envelope = self.ingress.verified_approval_resolution_envelope( + &event_id, + &self.actor_id, + &self.conversation_id, + gate_ref.as_str(), + decision, + )?; + Ok(self.workflow.submit_inbound(envelope).await?) + } + + /// Auth-side counterpart of [`submit_approval_resolution`](Self::submit_approval_resolution): + /// a REAL `submit_inbound(AuthResolution)`, dispatching through + /// `AuthInteractionService::resolve` instead of `resolve_auth_gate`/ + /// `deny_auth_gate`'s direct coordinator resume. + pub async fn submit_auth_resolution( + &self, + auth_request_ref: &str, + result: ironclaw_product_adapters::AuthResolutionResult, + ) -> HarnessResult { + let event_id = format!("evt-{}", self.event_seq.fetch_add(1, Ordering::Relaxed)); + let envelope = self.ingress.verified_auth_resolution_envelope( + &event_id, + &self.actor_id, + &self.conversation_id, + auth_request_ref, + result, + )?; + Ok(self.workflow.submit_inbound(envelope).await?) + } + /// Assert the finalized assistant reply in thread history contains `text`. pub async fn assert_reply_contains(&self, text: &str) -> HarnessResult<()> { self.thread_harness diff --git a/tests/integration/support/group.rs b/tests/integration/support/group.rs index 4e7854df877..c76cbd96b33 100644 --- a/tests/integration/support/group.rs +++ b/tests/integration/support/group.rs @@ -226,6 +226,10 @@ pub(crate) struct GroupSharedStorage { /// consumes the struct by value) so a parity test can read back the /// harness's REAL wiring shape, not a re-derived approximation. pub(crate) planned_runtime_parts_shape: DefaultPlannedRuntimePartsShape, + /// See `RebornIntegrationGroupBuilder::with_real_gate_dispatch_services`. + /// Read by `RebornThreadBuilder::build()` to decide whether to wire the + /// real approval/auth interaction services into the thread's workflow. + pub(crate) real_gate_dispatch_services: bool, } impl GroupSharedStorage { @@ -341,6 +345,7 @@ impl RebornIntegrationGroup { budget: false, communication_context_provider: None, hook_dispatcher_builder_factory: None, + real_gate_dispatch_services: false, } } @@ -529,6 +534,12 @@ pub struct RebornIntegrationGroupBuilder { /// lifecycle points on a coordinator-path turn. Default `None` (hook /// framework dormant, matching today's behavior). hook_dispatcher_builder_factory: Option, + /// When `true`, wire the REAL approval/auth interaction services into + /// every thread's `DefaultProductWorkflow` (see + /// `with_real_gate_dispatch_services`). Default `false` (every workflow + /// keeps the `Rejecting*InteractionService` stubs, matching today's + /// behavior byte-for-byte). + real_gate_dispatch_services: bool, } impl RebornIntegrationGroupBuilder { @@ -844,6 +855,7 @@ impl RebornIntegrationGroupBuilder { budget_governor, budget_account, planned_runtime_parts_shape, + real_gate_dispatch_services: self.real_gate_dispatch_services, }), }) } @@ -1115,7 +1127,47 @@ impl<'g> RebornThreadBuilder<'g> { let inbound: Arc = Arc::new(inbound_service); let ledger: Arc = Arc::new(shared.product_harness.idempotency_ledger()); - let workflow = DefaultProductWorkflow::new(inbound, ledger, binding_service); + let mut workflow = DefaultProductWorkflow::new(inbound, ledger, binding_service); + + // Real gate-dispatch seam: wire the harness's own local-dev interaction + // services, but over the GROUP's shared `turn_store` (not the harness's + // own disjoint `local_runtime.turn_state`) — otherwise their turn-run + // locator can never see this group's real runs. Only when the builder + // opted in (`with_real_gate_dispatch_services`); every other group's + // workflow keeps the default Rejecting stubs. + if shared.real_gate_dispatch_services { + let harness = match &shared.capability { + GroupCapability::HostRuntime(arc) => arc, + GroupCapability::Recording => { + return Err( + "with_real_gate_dispatch_services requires a HostRuntime capability backend" + .into(), + ); + } + }; + let reborn_services = harness.reborn_services_for_test().ok_or( + "with_real_gate_dispatch_services requires a harness built via new_with_options", + )?; + let approval_interaction_service = reborn_services + .local_dev_approval_interaction_service_with_turn_state_for_test( + Arc::clone(&shared.coordinator), + Arc::clone(&shared.turn_store), + )? + .ok_or( + "local-dev approval interaction service unavailable (harness has no local runtime)", + )?; + let auth_interaction_service = reborn_services + .local_dev_auth_interaction_service_with_turn_state_for_test( + Arc::clone(&shared.coordinator), + Arc::clone(&shared.turn_store), + ) + .ok_or( + "local-dev auth interaction service unavailable (harness has no local runtime)", + )?; + workflow = workflow + .with_approval_interaction_service(approval_interaction_service) + .with_auth_interaction_service(auth_interaction_service); + } // Register the gateway only now that every fallible (`?`) step above has // succeeded — registering earlier risks leaving the scope registered diff --git a/tests/integration/support/group_options.rs b/tests/integration/support/group_options.rs index f254d6ad527..28f3794a8cb 100644 --- a/tests/integration/support/group_options.rs +++ b/tests/integration/support/group_options.rs @@ -127,4 +127,19 @@ impl RebornIntegrationGroupBuilder { self.hook_dispatcher_builder_factory = Some(factory); self } + + /// Wire the REAL approval/auth interaction services (via the group's + /// `HostRuntimeCapabilityHarness`'s retained `RebornServices`, over the + /// group's own shared turn-state store) into every thread's + /// `DefaultProductWorkflow`, so `submit_inbound(ApprovalResolution/ + /// AuthResolution)` dispatches through the SAME arms a real adapter reply + /// hits, instead of every workflow's default `Rejecting*InteractionService` + /// stubs. Requires a `HostRuntime` capability backend built via + /// `new_with_options` (e.g. `live_approvals`, `live_auth_and_approval`) — + /// `RebornThreadBuilder::build()` errors otherwise. Defaults off (every + /// other group keeps today's Rejecting-stub behavior). + pub fn with_real_gate_dispatch_services(mut self) -> Self { + self.real_gate_dispatch_services = true; + self + } } diff --git a/tests/integration/support/harness/mod.rs b/tests/integration/support/harness/mod.rs index 15f64cdb84a..cfebc775277 100644 --- a/tests/integration/support/harness/mod.rs +++ b/tests/integration/support/harness/mod.rs @@ -247,6 +247,13 @@ pub(crate) struct HostRuntimeCapabilityHarness { /// Read via `trigger_repository_for_test` to wire /// `RebornAutomationProductFacade` over the same repo a prior turn used. trigger_repository: Option>, + /// The full `RebornServices` bundle this harness's `new_with_options` built + /// (`build_reborn_services`), retained so a group can build the REAL + /// approval/auth interaction services over it instead of the harness's + /// piecewise test-support accessors. `Some` only for `new_with_options`-built + /// harnesses; `None` for the lower-level constructors and the Echo backend. + /// Read via `reborn_services_for_test`. + reborn_services: Option, } impl HostRuntimeCapabilityHarness { @@ -527,8 +534,12 @@ impl HostRuntimeCapabilityHarness { None => None, }; let pending_approval_scopes = Arc::new(Mutex::new(HashMap::new())); + // `.clone()` (not a move) so `services` survives intact below — + // `reborn_services_for_test` needs the WHOLE `RebornServices` value, + // not just the pieces already extracted above. let runtime = services .host_runtime + .clone() .ok_or("local-dev Reborn services missing host runtime")?; let runtime = Arc::new(RecordingHostRuntime::new( runtime, @@ -568,9 +579,22 @@ impl HostRuntimeCapabilityHarness { tool_permission_overrides, persistent_approval_policies, trigger_repository, + reborn_services: Some(services), }) } + /// The full `RebornServices` bundle this harness was built from, if built + /// via `new_with_options`. Lets a caller build the REAL approval/auth + /// interaction services over this harness's own local-dev composition + /// (`RebornServices::local_dev_approval_interaction_service_with_turn_state_for_test` + /// et al.), e.g. so a group can wire genuine `submit_inbound`-driven + /// gate dispatch instead of the harness's direct-resume test shortcut. + pub(crate) fn reborn_services_for_test( + &self, + ) -> Option<&ironclaw_reborn_composition::RebornServices> { + self.reborn_services.as_ref() + } + pub(crate) fn capability_factory( self: &Arc, milestone_sink: Arc, diff --git a/tests/integration/support/harness/profiles/core_builtin.rs b/tests/integration/support/harness/profiles/core_builtin.rs index 9c5f3024823..3b093311765 100644 --- a/tests/integration/support/harness/profiles/core_builtin.rs +++ b/tests/integration/support/harness/profiles/core_builtin.rs @@ -232,5 +232,6 @@ fn core_builtin_tools_from_runtime( tool_permission_overrides: None, persistent_approval_policies: None, trigger_repository: None, + reborn_services: None, }) } diff --git a/tests/integration/support/harness/profiles/github.rs b/tests/integration/support/harness/profiles/github.rs index 5e1ada3572b..6dc87a314e5 100644 --- a/tests/integration/support/harness/profiles/github.rs +++ b/tests/integration/support/harness/profiles/github.rs @@ -180,5 +180,6 @@ fn github_issue_tools_with_credential_result( tool_permission_overrides: None, persistent_approval_policies: None, trigger_repository: None, + reborn_services: None, }) } diff --git a/tests/integration/support/harness/profiles/mock_mcp.rs b/tests/integration/support/harness/profiles/mock_mcp.rs index 65b390ffde8..36f5dd5d6ea 100644 --- a/tests/integration/support/harness/profiles/mock_mcp.rs +++ b/tests/integration/support/harness/profiles/mock_mcp.rs @@ -97,5 +97,6 @@ pub(crate) async fn mock_mcp_tools( tool_permission_overrides: None, persistent_approval_policies: None, trigger_repository: None, + reborn_services: None, }) } diff --git a/tests/integration/support/harness/profiles/qa_smoke.rs b/tests/integration/support/harness/profiles/qa_smoke.rs index 8267caaebf6..2933d0a0016 100644 --- a/tests/integration/support/harness/profiles/qa_smoke.rs +++ b/tests/integration/support/harness/profiles/qa_smoke.rs @@ -130,5 +130,6 @@ pub(crate) async fn qa_smoke_tools() -> HarnessResult HarnessResult Result { + let evidence = ProtocolAuthEvidence::test_verified(AuthRequirement::BearerToken, user_id); + let context = TrustedInboundContext::from_verified_evidence( + self.adapter.adapter_id().clone(), + self.adapter.installation_id().clone(), + Utc::now(), + &evidence, + )?; + let parsed = ParsedProductInbound::new( + ExternalEventId::new(event_id)?, + ExternalActorRef::new("reborn_test_user", user_id, Some(user_id.to_string()))?, + ExternalConversationRef::new(None, thread_id.to_string(), None, None)?, + ProductInboundPayload::ApprovalResolution(ApprovalResolutionPayload::new( + gate_ref, decision, + )?), + )?; + ProductInboundEnvelope::from_trusted_parse(context, parsed) + } + + /// A verified `AuthResolution` envelope for `submit_inbound`, the real + /// dispatch arm a product adapter's auth-gate reply hits. See + /// [`verified_approval_resolution_envelope`](Self::verified_approval_resolution_envelope) + /// for why this builds the `ParsedProductInbound` directly. + pub fn verified_auth_resolution_envelope( + &self, + event_id: &str, + user_id: &str, + thread_id: &str, + auth_request_ref: &str, + result: AuthResolutionResult, + ) -> Result { + let evidence = ProtocolAuthEvidence::test_verified(AuthRequirement::BearerToken, user_id); + let context = TrustedInboundContext::from_verified_evidence( + self.adapter.adapter_id().clone(), + self.adapter.installation_id().clone(), + Utc::now(), + &evidence, + )?; + let parsed = ParsedProductInbound::new( + ExternalEventId::new(event_id)?, + ExternalActorRef::new("reborn_test_user", user_id, Some(user_id.to_string()))?, + ExternalConversationRef::new(None, thread_id.to_string(), None, None)?, + ProductInboundPayload::AuthResolution(AuthResolutionPayload::new( + auth_request_ref, + result, + )?), + )?; + ProductInboundEnvelope::from_trusted_parse(context, parsed) + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] diff --git a/tests/integration/triggered_delivery_outcome.rs b/tests/integration/triggered_delivery_outcome.rs new file mode 100644 index 00000000000..329b59c79d8 --- /dev/null +++ b/tests/integration/triggered_delivery_outcome.rs @@ -0,0 +1,123 @@ +//! Triggered-delivery outcome seam at int tier. +//! +//! `TriggeredRunDeliveryOutcomeKind` (Delivered/Denied/Skipped/Failed/ +//! NoDefaultConfigured/TargetUnavailable) was previously observable ONLY at +//! crate tier (`ironclaw_reborn_composition::slack_delivery`'s `#[cfg(test)]` +//! module), which constructs `TriggeredRunDeliveryDriver` directly via `::new`, +//! never through the composition factory a real host binds. This proves the +//! REAL public factory — `build_triggered_run_delivery_hook(&runtime, &config, +//! delivery_store)` — assembles a working driver over a REAL local-dev +//! `RebornRuntime` (not a hand-built driver), and that the caller-supplied +//! `delivery_store` is genuinely the one it records through: a project-scoped +//! `TriggerFire` synchronously records `Denied` (`on_trigger_submitted`'s first +//! check, before any Slack egress/adapter is touched), read back via the exact +//! `Arc` this test injected. +//! +//! Does not drive a live trigger-poller fire (that full path — pairing, +//! seeding a due `TriggerRecord`, polling for the poller to claim it — is +//! already proven at crate tier, +//! `build_slack_host_beta_mounts_wires_trigger_delivery_hook_writes_record`). +//! This test's marginal value is the FACTORY construction path +//! (`build_triggered_run_delivery_hook` over a real `RebornRuntime`), not the +//! poller. + +use std::sync::Arc; + +use chrono::Utc; +use ironclaw_host_api::{AgentId, ProjectId, TenantId, ThreadId, UserId}; +use ironclaw_outbound::{ + InMemoryTriggeredRunDeliveryStore, TriggeredRunDeliveryOutcomeKind, TriggeredRunDeliveryStore, +}; +use ironclaw_reborn_composition::{ + PostSubmitDeliveryHook, RebornBuildInput, RebornRuntimeInput, SlackHostBetaChannelRoute, + SlackHostBetaConfig, SlackInstallationSelector, SlackTeamId, build_reborn_runtime, + build_triggered_run_delivery_hook, local_dev_runtime_policy, +}; +use ironclaw_triggers::{TriggerFire, TriggerFireIdentity, TriggerId}; +use ironclaw_turns::{TurnRunId, TurnScope}; +use secrecy::SecretString; + +fn slack_host_beta_config( + tenant_id: TenantId, + agent_id: AgentId, + user_id: UserId, +) -> SlackHostBetaConfig { + SlackHostBetaConfig { + tenant_id, + agent_id, + project_id: None, + installation_id: ironclaw_product_adapters::AdapterInstallationId::new( + "triggered-delivery-outcome-install", + ) + .expect("installation id"), + team_id: SlackTeamId::new("T-TRIGGERED-DELIVERY"), + installation_selector: SlackInstallationSelector::team("T-TRIGGERED-DELIVERY"), + slack_actor: None, + user_id, + shared_subject_user_id: None, + channel_routes: Vec::::new(), + signing_secret: SecretString::from("test-signing-secret"), + bot_token: SecretString::from("test-bot-token"), + } +} + +#[tokio::test] +async fn build_triggered_run_delivery_hook_over_real_runtime_records_denied_for_project_scoped_fire() + { + let root = tempfile::tempdir().expect("tempdir"); + let policy = local_dev_runtime_policy().expect("local-dev runtime policy resolves"); + let input = RebornBuildInput::local_dev( + "triggered-delivery-outcome-owner", + root.path().join("local-dev"), + ) + .with_runtime_policy(policy); + let runtime = build_reborn_runtime(RebornRuntimeInput::from_services(input)) + .await + .expect("local-dev runtime builds"); + + let tenant_id = TenantId::new("tenant-triggered-delivery").expect("tenant"); + let agent_id = AgentId::new("agent-triggered-delivery").expect("agent"); + let user_id = UserId::new("user-triggered-delivery").expect("user"); + let config = slack_host_beta_config(tenant_id.clone(), agent_id.clone(), user_id.clone()); + + // The real public factory, given OUR OWN injected store — the same + // caller-supplied-store seam a real host binds, not a test-only shortcut. + let delivery_store = Arc::new(InMemoryTriggeredRunDeliveryStore::default()); + let driver = build_triggered_run_delivery_hook(&runtime, &config, delivery_store.clone()) + .expect("real driver builds over a real local-dev RebornRuntime"); + + let run_id = TurnRunId::new(); + let trigger_id = TriggerId::new(); + let identity = TriggerFireIdentity::new(tenant_id.clone(), trigger_id, Utc::now()); + let project_id = ProjectId::new("some-project").expect("project id"); + let fire = TriggerFire { + identity, + creator_user_id: user_id.clone(), + agent_id: Some(agent_id.clone()), + project_id: Some(project_id.clone()), + prompt: "triggered-delivery-outcome-seam".to_string(), + }; + let scope = TurnScope::new_with_owner( + tenant_id.clone(), + Some(agent_id.clone()), + Some(project_id), + ThreadId::new("thread-triggered-delivery-outcome").expect("thread id"), + Some(user_id), + ); + + PostSubmitDeliveryHook::on_trigger_submitted(driver.as_ref(), fire, run_id, scope).await; + + let record = delivery_store + .load_triggered_run_delivery(run_id) + .await + .expect("load succeeds") + .expect("a record was written through OUR injected store"); + assert_eq!( + record.outcome, + TriggeredRunDeliveryOutcomeKind::Denied, + "a project-scoped trigger fire must record Denied, read back through the exact store \ + this test supplied to the real composition factory" + ); + + runtime.shutdown().await.expect("runtime shutdown"); +} From 517733762cc2c121cb66df1d820374114c118ed0 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 6 Jul 2026 15:42:04 -0700 Subject: [PATCH 2/5] =?UTF-8?q?test(reborn):=20W6-API2=20=E2=80=94=20webui?= =?UTF-8?q?=5Fv2=20mid-gate=20approval=20refresh=20over=20real=20gate=20di?= =?UTF-8?q?spatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser refresh mid-gate must let the user rediscover and resolve the pending gate. Mounts the real webui_v2 router over a hand-built RebornServices facade wired with the harness's own turn-state-converged ApprovalInteractionService (the same seam with_real_gate_dispatch_services uses for DefaultProductWorkflow); a fresh stream_events drain from after_cursor: None (the SSE handler is a polling wrapper over the same drain) surfaces the persisted GatePrompt event, and resolution goes through the real WEBUI_V2_PATTERN_RESOLVE_GATE HTTP route, not a direct-resume test shortcut. Two other legs of this seam (auth-gate mid-gate refresh, and a real AuthFlowRecord-backed submit_auth_resolution proof) turned out to be unreachable with the current int-tier harness: manual-token auth gates (GitHub, the only auth-gated capability any harness profile wires) never create an AuthFlowRecord at all — only Google-OAuth-gated capabilities do (oauth_gate.rs), and no harness profile wires one. Reaching either needs a new OAuth-gated capability profile, tracked as a follow-up rather than fiction-wired around here. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 4 + tests/integration/webui_v2_gate_refresh.rs | 147 +++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 tests/integration/webui_v2_gate_refresh.rs diff --git a/Cargo.toml b/Cargo.toml index f77e9e88a04..fce8214da6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -554,6 +554,10 @@ path = "tests/integration/triggered_submit.rs" name = "reborn_integration_web_access" path = "tests/integration/web_access.rs" +[[test]] +name = "reborn_integration_webui_v2_gate_refresh" +path = "tests/integration/webui_v2_gate_refresh.rs" + [[test]] name = "reborn_integration_webui_v2_product_api" path = "tests/integration/webui_v2_product_api.rs" diff --git a/tests/integration/webui_v2_gate_refresh.rs b/tests/integration/webui_v2_gate_refresh.rs new file mode 100644 index 00000000000..9e065803754 --- /dev/null +++ b/tests/integration/webui_v2_gate_refresh.rs @@ -0,0 +1,147 @@ +//! W5-WEBUI-API-2: a browser refresh mid-gate must let the user rediscover +//! and resolve a pending approval gate. Mounts the real `webui_v2` router +//! over a hand-built `RebornServices` facade (mirrors `webui_v2_product_api.rs`) +//! wired with the harness's own turn-state-converged +//! `ApprovalInteractionService` (`local_dev_approval_interaction_service_with_turn_state_for_test`, +//! the same seam `RebornIntegrationGroupBuilder::with_real_gate_dispatch_services` +//! wires into `DefaultProductWorkflow`) and the production event-stream recipe +//! `sse_activity_stream_replay_and_reconnect` already pins. +//! +//! "Refresh" is simulated the same way that precedent does: a fresh +//! `stream_events` drain with `after_cursor: None` — the SSE handler is a +//! polling wrapper over the same drain (W5-WEBUI-SPIKE), so this is +//! behaviorally equivalent to a browser opening a brand new `EventSource` +//! after a cold reload, without the fragility of reading a chunked HTTP body +//! through `tower::ServiceExt::oneshot`. + +#[allow(dead_code)] +#[path = "support/mod.rs"] +mod reborn_support; +#[allow(dead_code)] +#[path = "../support/mod.rs"] +mod support; + +use std::sync::Arc; + +use axum::http::StatusCode; +use ironclaw_events::InMemoryDurableEventLog; +use ironclaw_product_adapters::ProductOutboundPayload; +use ironclaw_product_workflow::{RebornServices, RebornServicesApi, RebornStreamEventsRequest}; +use ironclaw_turns::{ReplyTargetBindingRef, TurnEventProjectionSource, TurnStatus}; +use reborn_support::group::RebornIntegrationGroup; +use reborn_support::reply::RebornScriptedReply; +use reborn_support::webui_mount::{mount_webui_v2_router, post_json, webui_caller_for}; + +#[tokio::test] +async fn approval_gate_rediscovered_and_resolved_after_refresh() { + let group = RebornIntegrationGroup::live_approvals() + .await + .expect("live-approvals group builds"); + let h = group + .thread("conv-webui-api2-approval-refresh") + .script([ + RebornScriptedReply::tool_call( + "builtin.write_file", + serde_json::json!({"path": "/workspace/api2_refresh_approved.txt", "content": "API2_REFRESH_PAYLOAD"}), + ), + RebornScriptedReply::text("file written after the post-refresh approval"), + ]) + .build() + .await + .expect("thread builds"); + + let (run_id, gate_ref) = h + .submit_turn_until_blocked("write the api2 refresh file") + .await + .expect("blocks on a real approval gate"); + + // Wire the REAL approval interaction service over the group's own shared + // turn-state store — same test-support seam + // `with_real_gate_dispatch_services` uses for `DefaultProductWorkflow`, + // applied here directly to a webui-level `RebornServices` instead. + let capability_harness = group + .capability_harness() + .expect("live_approvals always uses a HostRuntime capability backend"); + let reborn_services = capability_harness + .reborn_services_for_test() + .expect("live_approvals harness is built via new_with_options"); + let approval_interactions = reborn_services + .local_dev_approval_interaction_service_with_turn_state_for_test( + h.coordinator.clone(), + h.turn_store.clone(), + ) + .expect("local-dev capability policy is valid") + .expect("harness has a local-dev runtime"); + + let event_log = Arc::new(InMemoryDurableEventLog::new()); + let reply_target_binding_ref = + ReplyTargetBindingRef::new("webui-api2-test").expect("valid reply target binding ref"); + let turn_event_source: Arc = h.turn_store.clone(); + let event_stream = ironclaw_reborn_composition::test_support::build_webui_event_stream_for_test( + event_log, + turn_event_source, + h.coordinator.clone(), + reply_target_binding_ref, + ); + let services: Arc = Arc::new( + RebornServices::new(h.thread_harness.service.clone(), h.coordinator.clone()) + .with_event_stream(event_stream) + .with_approval_interactions(approval_interactions), + ); + let caller = webui_caller_for(&h.binding); + let thread_id = h.binding.thread_id.as_str().to_string(); + + // --- simulate a cold browser refresh: fresh drain, after_cursor: None --- + let replayed = services + .stream_events( + caller.clone(), + RebornStreamEventsRequest { + thread_id: thread_id.clone(), + after_cursor: None, + }, + ) + .await + .expect("post-refresh drain succeeds"); + let gate_prompt = replayed + .events + .iter() + .find_map(|envelope| match &envelope.payload { + ProductOutboundPayload::GatePrompt(view) if view.gate_ref == gate_ref.as_str() => { + Some(view) + } + _ => None, + }) + .unwrap_or_else(|| { + panic!( + "expected the replayed cold-refresh drain to surface a GatePrompt for {gate_ref:?}: {:?}", + replayed.events + ) + }); + assert_eq!( + gate_prompt.turn_run_id, run_id, + "replayed gate prompt must be for the actual blocked run" + ); + + // --- resolve via the REAL route, not a direct-resume test shortcut --- + let (status, body) = post_json( + mount_webui_v2_router(services.clone(), caller), + &format!( + "/api/webchat/v2/threads/{thread_id}/runs/{run_id}/gates/{}/resolve", + gate_ref.as_str() + ), + serde_json::json!({ + "client_action_id": "webui-api2-approve-after-refresh", + "resolution": "approved", + "always": false, + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "resolve_gate response body: {body}"); + + h.wait_for_status(run_id, TurnStatus::Completed) + .await + .expect("run completes after the real resolve_gate route resumes it"); + h.assert_workspace_file_contains("api2_refresh_approved.txt", "API2_REFRESH_PAYLOAD") + .await + .expect("the approved write actually re-dispatched and persisted"); +} From 36b8db02d03e14c5cc3433c280bb0fe47a50c945 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 6 Jul 2026 16:58:14 -0700 Subject: [PATCH 3/5] fix(reborn): address review comments on gate-dispatch harness PR - Collapse TurnRunSnapshotSource/TriggerTurnSnapshotSource into one shared trait at crate-root turn_run_snapshot module; trigger_poller's SnapshotActiveRunLookup now maps TurnError -> TriggerError at its own boundary instead of duplicating the trait + blanket impls + a wrapper struct that turned out unnecessary once both consumers share the type. - submit_auth_resolution now takes &GateRef (matching submit_approval_resolution), converting to &str only at the envelope boundary. - Extract a shared verified_resolution_envelope helper for the approval/auth submit_inbound envelope builders. - Fix group_approvals module doc to describe both tests now present. Co-Authored-By: Claude Fable 5 --- crates/ironclaw_reborn_composition/src/lib.rs | 1 + .../src/runtime.rs | 11 +-- .../src/runtime/auth_interaction.rs | 2 +- .../src/trigger_poller.rs | 4 +- .../src/trigger_poller/active_run_lookup.rs | 90 +++++-------------- .../src/{runtime => }/turn_run_snapshot.rs | 20 +++-- tests/integration/group_approvals/main.rs | 27 +++--- tests/integration/support/builder.rs | 4 +- tests/integration/support/test_adapter.rs | 65 ++++++++------ 9 files changed, 103 insertions(+), 121 deletions(-) rename crates/ironclaw_reborn_composition/src/{runtime => }/turn_run_snapshot.rs (70%) diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index 8058ba8c412..a3c029aee78 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -147,6 +147,7 @@ mod support; pub mod test_support; mod trigger_poller; mod trigger_poller_trusted_submit; +mod turn_run_snapshot; mod web_access; mod webui; #[cfg(feature = "webui-v2-beta")] diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index 6d6127d5745..dfbd50e64f2 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -103,7 +103,6 @@ use ironclaw_turns::run_profile::UserProfileContext; use self::latency::{trace_runtime_latency_error, trace_runtime_latency_ok}; use self::runtime_turn_scheduler::RuntimeTurnScheduler; -pub(crate) use self::turn_run_snapshot::TurnRunSnapshotSource; use crate::default_system_prompt::DefaultSystemPromptIdentitySource; use crate::factory::{LocalDevRootFilesystem, LocalDevTurnStateStore, builtin_extension_registry}; use crate::local_dev_capability_policy::{LocalDevCapabilityPolicy, local_dev_capability_policy}; @@ -115,6 +114,7 @@ use crate::outbound::{ RebornOutboundPreferencesFacade, outbound_delivery_synthetic_provider, }; use crate::projection::{RebornProjectionServices, build_reborn_projection_services}; +pub(crate) use crate::turn_run_snapshot::TurnRunSnapshotSource; #[cfg(any(test, feature = "test-support"))] #[derive(Clone)] @@ -140,9 +140,8 @@ use crate::runtime_input::{ use crate::trigger_poller::TenantScopedTrustedTriggerFireAuthorizer; use crate::trigger_poller::{ AccessCheckerTriggerFireAuthorizer, ConversationContentRefMaterializer, - LocalTriggerTurnSnapshotSource, SnapshotActiveRunLookup, TRIGGER_POLLER_SHUTDOWN_TIMEOUT, - TriggerPollerCompositionDeps, TriggerPollerRuntimeHandle, TriggerTurnSnapshotSource, - spawn_trigger_poller, + SnapshotActiveRunLookup, TRIGGER_POLLER_SHUTDOWN_TIMEOUT, TriggerPollerCompositionDeps, + TriggerPollerRuntimeHandle, spawn_trigger_poller, }; use crate::{ RebornBuildError, RebornCompositionProfile, RebornProductAuthServices, RebornReadiness, @@ -356,7 +355,6 @@ mod skills; #[cfg(feature = "test-support")] #[path = "runtime/test_support.rs"] mod test_support; -mod turn_run_snapshot; #[cfg(feature = "test-support")] pub(crate) use local_dev::PROJECT_CREATE_CAPABILITY_ID; @@ -844,8 +842,7 @@ where fn build_trigger_active_run_lookup( turn_state_store: Arc, ) -> Arc { - let snapshot_source: Arc = - Arc::new(LocalTriggerTurnSnapshotSource::new(turn_state_store)); + let snapshot_source = turn_state_store as Arc; Arc::new(SnapshotActiveRunLookup::new(snapshot_source)) } diff --git a/crates/ironclaw_reborn_composition/src/runtime/auth_interaction.rs b/crates/ironclaw_reborn_composition/src/runtime/auth_interaction.rs index fb1b84986a6..e4ef522d5ec 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/auth_interaction.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/auth_interaction.rs @@ -13,7 +13,7 @@ use ironclaw_product_workflow::{ }; use ironclaw_turns::{GateRef, TurnPersistenceSnapshot, TurnRunId, TurnScope, TurnStatus}; -use super::turn_run_snapshot::TurnRunSnapshotSource; +use crate::turn_run_snapshot::TurnRunSnapshotSource; #[derive(Debug, Clone, PartialEq, Eq)] struct BlockedAuthRun { diff --git a/crates/ironclaw_reborn_composition/src/trigger_poller.rs b/crates/ironclaw_reborn_composition/src/trigger_poller.rs index baaca82c295..3d42c2f241c 100644 --- a/crates/ironclaw_reborn_composition/src/trigger_poller.rs +++ b/crates/ironclaw_reborn_composition/src/trigger_poller.rs @@ -30,9 +30,7 @@ pub(crate) use crate::trigger_poller_trusted_submit::ConversationContentRefMater pub(crate) use crate::trigger_poller_trusted_submit::TenantScopedTrustedTriggerFireAuthorizer; mod active_run_lookup; -pub(crate) use active_run_lookup::{ - LocalTriggerTurnSnapshotSource, SnapshotActiveRunLookup, TriggerTurnSnapshotSource, -}; +pub(crate) use active_run_lookup::SnapshotActiveRunLookup; pub(crate) const TRIGGER_POLLER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); diff --git a/crates/ironclaw_reborn_composition/src/trigger_poller/active_run_lookup.rs b/crates/ironclaw_reborn_composition/src/trigger_poller/active_run_lookup.rs index a519b5b48a9..39da1c72020 100644 --- a/crates/ironclaw_reborn_composition/src/trigger_poller/active_run_lookup.rs +++ b/crates/ironclaw_reborn_composition/src/trigger_poller/active_run_lookup.rs @@ -7,14 +7,16 @@ use ironclaw_triggers::{ }; use ironclaw_turns::{TurnPersistenceSnapshot, TurnStatus}; +use crate::turn_run_snapshot::TurnRunSnapshotSource; + type ActiveRunIndex = HashMap>; pub(crate) struct SnapshotActiveRunLookup { - snapshot_source: Arc, + snapshot_source: Arc, } impl SnapshotActiveRunLookup { - pub(crate) fn new(snapshot_source: Arc) -> Self { + pub(crate) fn new(snapshot_source: Arc) -> Self { Self { snapshot_source } } } @@ -25,7 +27,11 @@ impl TriggerActiveRunLookup for SnapshotActiveRunLookup { &self, request: TriggerActiveRunStateRequest, ) -> Result { - let snapshot = self.snapshot_source.snapshot().await?; + let snapshot = self + .snapshot_source + .turn_run_snapshot() + .await + .map_err(trigger_backend_error)?; let run_index = active_run_index(&snapshot); Ok(active_run_state_from_index(&run_index, &request)) } @@ -37,10 +43,10 @@ impl TriggerActiveRunLookup for SnapshotActiveRunLookup { if requests.is_empty() { return Vec::new(); } - let snapshot = match self.snapshot_source.snapshot().await { + let snapshot = match self.snapshot_source.turn_run_snapshot().await { Ok(snapshot) => snapshot, Err(error) => { - let reason = error.to_string(); + let reason = trigger_backend_error(error).to_string(); return requests .into_iter() .map(|_| { @@ -109,60 +115,6 @@ fn terminal_run_history_status(status: TurnStatus) -> TriggerRunHistoryStatus { } } -#[async_trait] -pub(crate) trait TriggerTurnSnapshotSource: Send + Sync { - async fn snapshot(&self) -> Result; -} - -pub(crate) struct LocalTriggerTurnSnapshotSource { - store: Arc, -} - -impl LocalTriggerTurnSnapshotSource { - pub(crate) fn new(store: Arc) -> Self { - Self { store } - } -} - -// Durable filesystem store (libSQL/Postgres, without `inmemory-turn-state`): -// async `Result`. -#[cfg(all( - any(feature = "libsql", feature = "postgres"), - not(feature = "inmemory-turn-state") -))] -#[async_trait] -impl TriggerTurnSnapshotSource - for LocalTriggerTurnSnapshotSource> -where - F: ironclaw_filesystem::RootFilesystem + Send + Sync + 'static, -{ - async fn snapshot(&self) -> Result { - self.store - .persistence_snapshot() - .await - .map_err(trigger_backend_error) - } -} - -// In-memory authority (no-DB builds, or any build with `inmemory-turn-state`): -// sync infallible snapshot. -#[cfg(any( - feature = "inmemory-turn-state", - not(any(feature = "libsql", feature = "postgres")) -))] -#[async_trait] -impl TriggerTurnSnapshotSource - for LocalTriggerTurnSnapshotSource -{ - async fn snapshot(&self) -> Result { - Ok(self.store.persistence_snapshot()) - } -} - -#[cfg(all( - any(feature = "libsql", feature = "postgres"), - not(feature = "inmemory-turn-state") -))] fn trigger_backend_error(error: impl std::fmt::Display) -> TriggerError { TriggerError::Backend { reason: error.to_string(), @@ -197,8 +149,10 @@ mod tests { } #[async_trait] - impl TriggerTurnSnapshotSource for CountingSnapshotSource { - async fn snapshot(&self) -> Result { + impl TurnRunSnapshotSource for CountingSnapshotSource { + async fn turn_run_snapshot( + &self, + ) -> Result { *self.calls.lock().expect("snapshot calls lock") += 1; Ok(TurnPersistenceSnapshot::default()) } @@ -209,8 +163,10 @@ mod tests { } #[async_trait] - impl TriggerTurnSnapshotSource for StaticSnapshotSource { - async fn snapshot(&self) -> Result { + impl TurnRunSnapshotSource for StaticSnapshotSource { + async fn turn_run_snapshot( + &self, + ) -> Result { Ok(self.snapshot.clone()) } } @@ -227,10 +183,12 @@ mod tests { } #[async_trait] - impl TriggerTurnSnapshotSource for FailingSnapshotSource { - async fn snapshot(&self) -> Result { + impl TurnRunSnapshotSource for FailingSnapshotSource { + async fn turn_run_snapshot( + &self, + ) -> Result { *self.calls.lock().expect("snapshot calls lock") += 1; - Err(TriggerError::Backend { + Err(ironclaw_turns::TurnError::Unavailable { reason: "snapshot failed".to_string(), }) } diff --git a/crates/ironclaw_reborn_composition/src/runtime/turn_run_snapshot.rs b/crates/ironclaw_reborn_composition/src/turn_run_snapshot.rs similarity index 70% rename from crates/ironclaw_reborn_composition/src/runtime/turn_run_snapshot.rs rename to crates/ironclaw_reborn_composition/src/turn_run_snapshot.rs index 260c636eae8..406f322d042 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/turn_run_snapshot.rs +++ b/crates/ironclaw_reborn_composition/src/turn_run_snapshot.rs @@ -1,7 +1,9 @@ -//! Turn-run persistence-snapshot abstraction shared by the local-dev -//! approval/auth interaction locators (`LocalDevApprovalTurnRunLocator` in -//! `runtime.rs`, `LocalDevAuthInteractionReadModel` in -//! `runtime/auth_interaction.rs`). +//! Turn-run persistence-snapshot abstraction shared by every reader of live +//! turn-run state in this crate: the local-dev approval/auth interaction +//! locators (`LocalDevApprovalTurnRunLocator` in `runtime.rs`, +//! `LocalDevAuthInteractionReadModel` in `runtime/auth_interaction.rs`) and +//! the trigger poller's active-run lookup (`SnapshotActiveRunLookup` in +//! `trigger_poller/active_run_lookup.rs`). //! //! Exists so a `test-support` caller can substitute the turn-state store a //! locator reads from without those locators depending on the specific @@ -15,6 +17,11 @@ //! as the source, which implements this trait via the blanket impls below, so //! its snapshot behavior is byte-identical to before this seam existed — this //! module only replaces a hardcoded field type with a trait-object one. +//! +//! Returns the raw `ironclaw_turns::TurnError`; each consumer maps it into +//! its own domain error at its own boundary (`ProductWorkflowError` for the +//! approval/auth locators, `TriggerError` for the trigger poller) rather than +//! this shared substrate trait picking a consumer's error type. use async_trait::async_trait; use ironclaw_turns::{TurnError, TurnPersistenceSnapshot}; @@ -30,8 +37,9 @@ pub(crate) trait TurnRunSnapshotSource: Send + Sync { // and a caller's own store (e.g. `RebornIntegrationGroup`'s // `FilesystemTurnStateStore`) implement this identically. // Unconditional (not cfg-gated on which backend `LocalDevTurnStateStore` -// happens to alias to in this build): `FilesystemTurnStateStore::persistence_snapshot` -// is always defined, and this impl targets a different concrete type per `F` +// happens to alias to in this build, nor on this crate's `libsql`/`postgres` +// features): `FilesystemTurnStateStore` is defined unconditionally in +// `ironclaw_turns`, and this impl targets a different concrete type per `F` // than the `InMemoryTurnStateStore` impl below, so the two never conflict. #[async_trait] impl TurnRunSnapshotSource for ironclaw_turns::FilesystemTurnStateStore diff --git a/tests/integration/group_approvals/main.rs b/tests/integration/group_approvals/main.rs index 6af0d6435fc..57c22d82d27 100644 --- a/tests/integration/group_approvals/main.rs +++ b/tests/integration/group_approvals/main.rs @@ -1,16 +1,23 @@ //! Group integration tests for the Reborn approval flow — the real gate path. //! -//! One sequential `#[tokio::test]` drives eight scenarios over a shared -//! [`RebornIntegrationGroup::live_approvals`] group (one approval-request store, -//! one capability-lease store, one `(tenant, user)` auto-approve toggle, all -//! shared across threads). See `tests/integration/CLAUDE.md` §"Group tests". +//! `approvals_group_e2e` is one sequential `#[tokio::test]` that drives eight +//! scenarios over a shared [`RebornIntegrationGroup::live_approvals`] group +//! (one approval-request store, one capability-lease store, one +//! `(tenant, user)` auto-approve toggle, all shared across threads). See +//! `tests/integration/CLAUDE.md` §"Group tests". //! -//! Every scenario drives the REAL gate path: scripted `builtin.write_file` call -//! → real `TurnStatus::BlockedApproval` gate (auto-approve disabled for the -//! group at construction) → real `ApprovalResolver` (`approve_gate`/`deny_gate`) -//! → `coordinator.resume_turn`. Only the model is faked. Exception: -//! `failure_category_demasked` drives a genuinely-FAILED run (no gate) to prove -//! the loop-exit de-mask wiring. +//! Every scenario in that test drives the REAL gate path: scripted +//! `builtin.write_file` call → real `TurnStatus::BlockedApproval` gate +//! (auto-approve disabled for the group at construction) → real +//! `ApprovalResolver` (`approve_gate`/`deny_gate`) → `coordinator.resume_turn`. +//! Only the model is faked. Exception: `failure_category_demasked` drives a +//! genuinely-FAILED run (no gate) to prove the loop-exit de-mask wiring. +//! +//! `approvals_group_real_gate_dispatch_e2e` is a separate group/test proving +//! the `submit_inbound(ApprovalResolution)` dispatch arm instead: it wires the +//! real interaction services over the group's own shared turn-state store so +//! resolution reaches the literal dispatch arm a real adapter's "approve"/ +//! "deny" reply hits, rather than resuming the turn directly. //! //! ## Ordering (state machine over the shared auto-approve store) //! diff --git a/tests/integration/support/builder.rs b/tests/integration/support/builder.rs index 51a778ef6ea..16d877a0623 100644 --- a/tests/integration/support/builder.rs +++ b/tests/integration/support/builder.rs @@ -747,7 +747,7 @@ impl RebornIntegrationHarness { /// `deny_auth_gate`'s direct coordinator resume. pub async fn submit_auth_resolution( &self, - auth_request_ref: &str, + gate_ref: &GateRef, result: ironclaw_product_adapters::AuthResolutionResult, ) -> HarnessResult { let event_id = format!("evt-{}", self.event_seq.fetch_add(1, Ordering::Relaxed)); @@ -755,7 +755,7 @@ impl RebornIntegrationHarness { &event_id, &self.actor_id, &self.conversation_id, - auth_request_ref, + gate_ref.as_str(), result, )?; Ok(self.workflow.submit_inbound(envelope).await?) diff --git a/tests/integration/support/test_adapter.rs b/tests/integration/support/test_adapter.rs index 350286828de..137077583a5 100644 --- a/tests/integration/support/test_adapter.rs +++ b/tests/integration/support/test_adapter.rs @@ -315,21 +315,18 @@ impl RebornTestIngress { self.adapter.parse_inbound(raw_payload, &evidence) } - /// A verified `ApprovalResolution` envelope for `submit_inbound`, the real - /// dispatch arm a product adapter's "approve"/"deny" reply hits. - /// `ApprovalResolution` has no wire-format representation in - /// `RebornTestProductAdapter`'s JSON payload enum (it only carries - /// `UserMessage`/`SubscriptionRequest`), so this builds the - /// `ParsedProductInbound` directly instead of round-tripping through - /// `parse_inbound` — matching the adapter-shaped value a real adapter would - /// hand `submit_inbound`. - pub fn verified_approval_resolution_envelope( + /// A verified `submit_inbound` envelope wrapping an already-built + /// resolution payload. Shared by + /// [`verified_approval_resolution_envelope`](Self::verified_approval_resolution_envelope) + /// and + /// [`verified_auth_resolution_envelope`](Self::verified_auth_resolution_envelope), + /// which differ only in which `ProductInboundPayload` variant they pass. + fn verified_resolution_envelope( &self, event_id: &str, user_id: &str, thread_id: &str, - gate_ref: &str, - decision: ApprovalDecision, + payload: ProductInboundPayload, ) -> Result { let evidence = ProtocolAuthEvidence::test_verified(AuthRequirement::BearerToken, user_id); let context = TrustedInboundContext::from_verified_evidence( @@ -342,11 +339,35 @@ impl RebornTestIngress { ExternalEventId::new(event_id)?, ExternalActorRef::new("reborn_test_user", user_id, Some(user_id.to_string()))?, ExternalConversationRef::new(None, thread_id.to_string(), None, None)?, + payload, + )?; + ProductInboundEnvelope::from_trusted_parse(context, parsed) + } + + /// A verified `ApprovalResolution` envelope for `submit_inbound`, the real + /// dispatch arm a product adapter's "approve"/"deny" reply hits. + /// `ApprovalResolution` has no wire-format representation in + /// `RebornTestProductAdapter`'s JSON payload enum (it only carries + /// `UserMessage`/`SubscriptionRequest`), so this builds the + /// `ParsedProductInbound` directly instead of round-tripping through + /// `parse_inbound` — matching the adapter-shaped value a real adapter would + /// hand `submit_inbound`. + pub fn verified_approval_resolution_envelope( + &self, + event_id: &str, + user_id: &str, + thread_id: &str, + gate_ref: &str, + decision: ApprovalDecision, + ) -> Result { + self.verified_resolution_envelope( + event_id, + user_id, + thread_id, ProductInboundPayload::ApprovalResolution(ApprovalResolutionPayload::new( gate_ref, decision, )?), - )?; - ProductInboundEnvelope::from_trusted_parse(context, parsed) + ) } /// A verified `AuthResolution` envelope for `submit_inbound`, the real @@ -361,23 +382,15 @@ impl RebornTestIngress { auth_request_ref: &str, result: AuthResolutionResult, ) -> Result { - let evidence = ProtocolAuthEvidence::test_verified(AuthRequirement::BearerToken, user_id); - let context = TrustedInboundContext::from_verified_evidence( - self.adapter.adapter_id().clone(), - self.adapter.installation_id().clone(), - Utc::now(), - &evidence, - )?; - let parsed = ParsedProductInbound::new( - ExternalEventId::new(event_id)?, - ExternalActorRef::new("reborn_test_user", user_id, Some(user_id.to_string()))?, - ExternalConversationRef::new(None, thread_id.to_string(), None, None)?, + self.verified_resolution_envelope( + event_id, + user_id, + thread_id, ProductInboundPayload::AuthResolution(AuthResolutionPayload::new( auth_request_ref, result, )?), - )?; - ProductInboundEnvelope::from_trusted_parse(context, parsed) + ) } } From 9bf3419d98bbeb2ba1bfe2b9d1441d1e9896479d Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 6 Jul 2026 17:05:40 -0700 Subject: [PATCH 4/5] test(reborn): consolidate gate-refresh scenario into webui_v2_product_api bin Move approval_gate_rediscovered_and_resolved_after_refresh into the existing W5-WEBUI-API-1 product-API test binary instead of registering a second one-test bin for the same hand-built RebornServices/WebUI facade setup. Test content is unchanged. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 4 - tests/integration/webui_v2_gate_refresh.rs | 147 --------------------- tests/integration/webui_v2_product_api.rs | 131 +++++++++++++++++- 3 files changed, 130 insertions(+), 152 deletions(-) delete mode 100644 tests/integration/webui_v2_gate_refresh.rs diff --git a/Cargo.toml b/Cargo.toml index 561c5657494..1e1b9eafbc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -560,10 +560,6 @@ path = "tests/integration/triggered_submit.rs" name = "reborn_integration_web_access" path = "tests/integration/web_access.rs" -[[test]] -name = "reborn_integration_webui_v2_gate_refresh" -path = "tests/integration/webui_v2_gate_refresh.rs" - [[test]] name = "reborn_integration_webui_v2_product_api" path = "tests/integration/webui_v2_product_api.rs" diff --git a/tests/integration/webui_v2_gate_refresh.rs b/tests/integration/webui_v2_gate_refresh.rs deleted file mode 100644 index 9e065803754..00000000000 --- a/tests/integration/webui_v2_gate_refresh.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! W5-WEBUI-API-2: a browser refresh mid-gate must let the user rediscover -//! and resolve a pending approval gate. Mounts the real `webui_v2` router -//! over a hand-built `RebornServices` facade (mirrors `webui_v2_product_api.rs`) -//! wired with the harness's own turn-state-converged -//! `ApprovalInteractionService` (`local_dev_approval_interaction_service_with_turn_state_for_test`, -//! the same seam `RebornIntegrationGroupBuilder::with_real_gate_dispatch_services` -//! wires into `DefaultProductWorkflow`) and the production event-stream recipe -//! `sse_activity_stream_replay_and_reconnect` already pins. -//! -//! "Refresh" is simulated the same way that precedent does: a fresh -//! `stream_events` drain with `after_cursor: None` — the SSE handler is a -//! polling wrapper over the same drain (W5-WEBUI-SPIKE), so this is -//! behaviorally equivalent to a browser opening a brand new `EventSource` -//! after a cold reload, without the fragility of reading a chunked HTTP body -//! through `tower::ServiceExt::oneshot`. - -#[allow(dead_code)] -#[path = "support/mod.rs"] -mod reborn_support; -#[allow(dead_code)] -#[path = "../support/mod.rs"] -mod support; - -use std::sync::Arc; - -use axum::http::StatusCode; -use ironclaw_events::InMemoryDurableEventLog; -use ironclaw_product_adapters::ProductOutboundPayload; -use ironclaw_product_workflow::{RebornServices, RebornServicesApi, RebornStreamEventsRequest}; -use ironclaw_turns::{ReplyTargetBindingRef, TurnEventProjectionSource, TurnStatus}; -use reborn_support::group::RebornIntegrationGroup; -use reborn_support::reply::RebornScriptedReply; -use reborn_support::webui_mount::{mount_webui_v2_router, post_json, webui_caller_for}; - -#[tokio::test] -async fn approval_gate_rediscovered_and_resolved_after_refresh() { - let group = RebornIntegrationGroup::live_approvals() - .await - .expect("live-approvals group builds"); - let h = group - .thread("conv-webui-api2-approval-refresh") - .script([ - RebornScriptedReply::tool_call( - "builtin.write_file", - serde_json::json!({"path": "/workspace/api2_refresh_approved.txt", "content": "API2_REFRESH_PAYLOAD"}), - ), - RebornScriptedReply::text("file written after the post-refresh approval"), - ]) - .build() - .await - .expect("thread builds"); - - let (run_id, gate_ref) = h - .submit_turn_until_blocked("write the api2 refresh file") - .await - .expect("blocks on a real approval gate"); - - // Wire the REAL approval interaction service over the group's own shared - // turn-state store — same test-support seam - // `with_real_gate_dispatch_services` uses for `DefaultProductWorkflow`, - // applied here directly to a webui-level `RebornServices` instead. - let capability_harness = group - .capability_harness() - .expect("live_approvals always uses a HostRuntime capability backend"); - let reborn_services = capability_harness - .reborn_services_for_test() - .expect("live_approvals harness is built via new_with_options"); - let approval_interactions = reborn_services - .local_dev_approval_interaction_service_with_turn_state_for_test( - h.coordinator.clone(), - h.turn_store.clone(), - ) - .expect("local-dev capability policy is valid") - .expect("harness has a local-dev runtime"); - - let event_log = Arc::new(InMemoryDurableEventLog::new()); - let reply_target_binding_ref = - ReplyTargetBindingRef::new("webui-api2-test").expect("valid reply target binding ref"); - let turn_event_source: Arc = h.turn_store.clone(); - let event_stream = ironclaw_reborn_composition::test_support::build_webui_event_stream_for_test( - event_log, - turn_event_source, - h.coordinator.clone(), - reply_target_binding_ref, - ); - let services: Arc = Arc::new( - RebornServices::new(h.thread_harness.service.clone(), h.coordinator.clone()) - .with_event_stream(event_stream) - .with_approval_interactions(approval_interactions), - ); - let caller = webui_caller_for(&h.binding); - let thread_id = h.binding.thread_id.as_str().to_string(); - - // --- simulate a cold browser refresh: fresh drain, after_cursor: None --- - let replayed = services - .stream_events( - caller.clone(), - RebornStreamEventsRequest { - thread_id: thread_id.clone(), - after_cursor: None, - }, - ) - .await - .expect("post-refresh drain succeeds"); - let gate_prompt = replayed - .events - .iter() - .find_map(|envelope| match &envelope.payload { - ProductOutboundPayload::GatePrompt(view) if view.gate_ref == gate_ref.as_str() => { - Some(view) - } - _ => None, - }) - .unwrap_or_else(|| { - panic!( - "expected the replayed cold-refresh drain to surface a GatePrompt for {gate_ref:?}: {:?}", - replayed.events - ) - }); - assert_eq!( - gate_prompt.turn_run_id, run_id, - "replayed gate prompt must be for the actual blocked run" - ); - - // --- resolve via the REAL route, not a direct-resume test shortcut --- - let (status, body) = post_json( - mount_webui_v2_router(services.clone(), caller), - &format!( - "/api/webchat/v2/threads/{thread_id}/runs/{run_id}/gates/{}/resolve", - gate_ref.as_str() - ), - serde_json::json!({ - "client_action_id": "webui-api2-approve-after-refresh", - "resolution": "approved", - "always": false, - }), - ) - .await; - assert_eq!(status, StatusCode::OK, "resolve_gate response body: {body}"); - - h.wait_for_status(run_id, TurnStatus::Completed) - .await - .expect("run completes after the real resolve_gate route resumes it"); - h.assert_workspace_file_contains("api2_refresh_approved.txt", "API2_REFRESH_PAYLOAD") - .await - .expect("the approved write actually re-dispatched and persisted"); -} diff --git a/tests/integration/webui_v2_product_api.rs b/tests/integration/webui_v2_product_api.rs index dcf49602efd..91c1d76d2c5 100644 --- a/tests/integration/webui_v2_product_api.rs +++ b/tests/integration/webui_v2_product_api.rs @@ -22,7 +22,7 @@ use ironclaw_product_workflow::{ RebornOperatorToolCatalog, RebornOperatorToolInfo, RebornServices, RebornServicesApi, RebornStreamEventsRequest, }; -use ironclaw_turns::{ReplyTargetBindingRef, TurnEventProjectionSource}; +use ironclaw_turns::{ReplyTargetBindingRef, TurnEventProjectionSource, TurnStatus}; use reborn_support::builder::{RebornIntegrationHarness, StorageMode}; use reborn_support::group::RebornIntegrationGroup; use reborn_support::reply::RebornScriptedReply; @@ -317,3 +317,132 @@ async fn sse_activity_stream_replay_and_reconnect() { second.events ); } + +/// W5-WEBUI-API-2: a browser refresh mid-gate must let the user rediscover +/// and resolve a pending approval gate. Mounts the real `webui_v2` router +/// over a hand-built `RebornServices` facade wired with the harness's own +/// turn-state-converged `ApprovalInteractionService` +/// (`local_dev_approval_interaction_service_with_turn_state_for_test`, the +/// same seam `RebornIntegrationGroupBuilder::with_real_gate_dispatch_services` +/// wires into `DefaultProductWorkflow`) and the production event-stream +/// recipe `sse_activity_stream_replay_and_reconnect` above already pins. +/// +/// "Refresh" is simulated the same way that precedent does: a fresh +/// `stream_events` drain with `after_cursor: None` — the SSE handler is a +/// polling wrapper over the same drain (W5-WEBUI-SPIKE), so this is +/// behaviorally equivalent to a browser opening a brand new `EventSource` +/// after a cold reload, without the fragility of reading a chunked HTTP body +/// through `tower::ServiceExt::oneshot`. +#[tokio::test] +async fn approval_gate_rediscovered_and_resolved_after_refresh() { + let group = RebornIntegrationGroup::live_approvals() + .await + .expect("live-approvals group builds"); + let h = group + .thread("conv-webui-api2-approval-refresh") + .script([ + RebornScriptedReply::tool_call( + "builtin.write_file", + serde_json::json!({"path": "/workspace/api2_refresh_approved.txt", "content": "API2_REFRESH_PAYLOAD"}), + ), + RebornScriptedReply::text("file written after the post-refresh approval"), + ]) + .build() + .await + .expect("thread builds"); + + let (run_id, gate_ref) = h + .submit_turn_until_blocked("write the api2 refresh file") + .await + .expect("blocks on a real approval gate"); + + // Wire the REAL approval interaction service over the group's own shared + // turn-state store — same test-support seam + // `with_real_gate_dispatch_services` uses for `DefaultProductWorkflow`, + // applied here directly to a webui-level `RebornServices` instead. + let capability_harness = group + .capability_harness() + .expect("live_approvals always uses a HostRuntime capability backend"); + let reborn_services = capability_harness + .reborn_services_for_test() + .expect("live_approvals harness is built via new_with_options"); + let approval_interactions = reborn_services + .local_dev_approval_interaction_service_with_turn_state_for_test( + h.coordinator.clone(), + h.turn_store.clone(), + ) + .expect("local-dev capability policy is valid") + .expect("harness has a local-dev runtime"); + + let event_log = Arc::new(InMemoryDurableEventLog::new()); + let reply_target_binding_ref = + ReplyTargetBindingRef::new("webui-api2-test").expect("valid reply target binding ref"); + let turn_event_source: Arc = h.turn_store.clone(); + let event_stream = ironclaw_reborn_composition::test_support::build_webui_event_stream_for_test( + event_log, + turn_event_source, + h.coordinator.clone(), + reply_target_binding_ref, + ); + let services: Arc = Arc::new( + RebornServices::new(h.thread_harness.service.clone(), h.coordinator.clone()) + .with_event_stream(event_stream) + .with_approval_interactions(approval_interactions), + ); + let caller = webui_caller_for(&h.binding); + let thread_id = h.binding.thread_id.as_str().to_string(); + + // --- simulate a cold browser refresh: fresh drain, after_cursor: None --- + let replayed = services + .stream_events( + caller.clone(), + RebornStreamEventsRequest { + thread_id: thread_id.clone(), + after_cursor: None, + }, + ) + .await + .expect("post-refresh drain succeeds"); + let gate_prompt = replayed + .events + .iter() + .find_map(|envelope| match &envelope.payload { + ProductOutboundPayload::GatePrompt(view) if view.gate_ref == gate_ref.as_str() => { + Some(view) + } + _ => None, + }) + .unwrap_or_else(|| { + panic!( + "expected the replayed cold-refresh drain to surface a GatePrompt for {gate_ref:?}: {:?}", + replayed.events + ) + }); + assert_eq!( + gate_prompt.turn_run_id, run_id, + "replayed gate prompt must be for the actual blocked run" + ); + + // --- resolve via the REAL route, not a direct-resume test shortcut --- + let (status, body) = post_json( + mount_webui_v2_router(services.clone(), caller), + &format!( + "/api/webchat/v2/threads/{thread_id}/runs/{run_id}/gates/{}/resolve", + gate_ref.as_str() + ), + serde_json::json!({ + "client_action_id": "webui-api2-approve-after-refresh", + "resolution": "approved", + "always": false, + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "resolve_gate response body: {body}"); + + h.wait_for_status(run_id, TurnStatus::Completed) + .await + .expect("run completes after the real resolve_gate route resumes it"); + h.assert_workspace_file_contains("api2_refresh_approved.txt", "API2_REFRESH_PAYLOAD") + .await + .expect("the approved write actually re-dispatched and persisted"); +} From df2fdc7db71911f9229d2ffa0155bf2db2c7772f Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 6 Jul 2026 17:12:44 -0700 Subject: [PATCH 5/5] fix(reborn): drop unused pub(crate) re-export of TurnRunSnapshotSource No in-crate consumer imports it via the runtime module path; all of them (trigger_poller, auth_interaction, test_support via super::*) resolve it through crate::turn_run_snapshot directly. Co-Authored-By: Claude Fable 5 --- crates/ironclaw_reborn_composition/src/runtime.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index dfbd50e64f2..c1366e868fb 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -114,7 +114,7 @@ use crate::outbound::{ RebornOutboundPreferencesFacade, outbound_delivery_synthetic_provider, }; use crate::projection::{RebornProjectionServices, build_reborn_projection_services}; -pub(crate) use crate::turn_run_snapshot::TurnRunSnapshotSource; +use crate::turn_run_snapshot::TurnRunSnapshotSource; #[cfg(any(test, feature = "test-support"))] #[derive(Clone)]