Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,10 @@ path = "tests/reborn_group_memory/main.rs"
name = "reborn_group_extensions"
path = "tests/reborn_group_extensions/main.rs"

[[test]]
name = "reborn_group_triggers"
path = "tests/reborn_group_triggers/main.rs"

[[test]]
name = "reborn_integration_oauth_refresh"
required-features = ["libsql"]
Expand Down
60 changes: 60 additions & 0 deletions tests/reborn_group_triggers/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//! Group integration tests for the trigger-management verbs at int tier.
//!
//! A [`RebornIntegrationGroup::triggers`] owns one shared
//! `HostRuntimeCapabilityHarness` (one trigger repository). The five verbs
//! (`trigger_create`/`list`/`pause`/`resume`/`remove`) are dispatched through
//! the real agent-loop turn → capability path — the only int-tier coverage of
//! these handlers (composition-tier `trigger_poller_e2e.rs` invokes only
//! `trigger_create` directly, and the one-shot fire → `Completed` derivation is
//! already covered there + in `repository_contract.rs`, so this binary does NOT
//! re-cover firing/completion/outbound — it fills the verb-dispatch gap).
//!
//! ## Why one sequential `#[tokio::test]`
//!
//! The scenario spans two threads over the SAME trigger scope: thread A mints a
//! `trigger_id` the static script cannot know ahead of time; thread B must run
//! after A to pause/resume/remove that id over the shared repo. One
//! orchestrating function gives deterministic ordering for free.

#[allow(dead_code)]
#[path = "../support/reborn/mod.rs"]
mod reborn_support;
#[allow(dead_code)]
#[path = "../support/mod.rs"]
mod support;

mod scenario_verbs_lifecycle;

use reborn_support::group::{RebornIntegrationGroup, ScenarioReport};

#[tokio::test]
async fn triggers_group_e2e() {
let g = RebornIntegrationGroup::triggers()
.await
.expect("group builds");
let mut report = ScenarioReport::new();

// HEADLINE: create a one-shot Once trigger + list it in thread A, then
// pause → resume → remove it by id in thread B over the shared repo.
report.record("verbs_lifecycle", scenario_verbs_lifecycle::run(&g).await);

// TODO(triggered-turn follow-ups): coverage intentionally left OUT of this
// binary because it needs a harness seam that does not exist yet — a way to
// submit a turn carrying `TurnOriginKind::ScheduledTrigger` (the
// `TrustedTriggerFireSubmitter` path), not the direct-chat submit this group
// uses. Add these only once that seam lands; do not hand-roll a weaker
// stand-in:
// - a triggered turn that raises a real `BlockedApproval` gate mid-fire →
// approve/deny → resume;
// - assert a triggered fire propagates `TurnOriginKind::ScheduledTrigger`
// end to end;
// - triggered run → outbound delivery sink got the payload + reply target.
// What is ALREADY covered elsewhere (do NOT duplicate here): the one-shot
// Once fire → `Completed` derivation lives in
// `crates/ironclaw_reborn_composition/tests/trigger_poller_e2e.rs` +
// `crates/ironclaw_triggers/tests/repository_contract.rs`; the trigger →
// Slack outbound-delivery leg lives in the trigger-delivery-hook tests in
// `crates/ironclaw_reborn_composition/src/slack_host_beta.rs`.

report.assert_all_passed();
}
156 changes: 156 additions & 0 deletions tests/reborn_group_triggers/scenario_verbs_lifecycle.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//! HEADLINE: the full trigger-management verb lifecycle at int tier.
//!
//! Thread A dispatches `builtin.trigger_create` (a one-shot `Once{at}` schedule)
//! then `builtin.trigger_list`, and reads back the server-minted `trigger_id`.
//! Thread B — a DIFFERENT conversation over the SAME trigger scope (shared repo)
//! — dispatches `trigger_pause` → `trigger_resume` → `trigger_remove` against
//! that id, then `trigger_list` to confirm the removal took. Because the two
//! threads share the `HostRuntimeCapabilityHarness` trigger repository, thread B
//! operating on thread A's trigger also proves cross-thread persistence.
//!
//! This is the only integration coverage of the `list`/`pause`/`resume`/`remove`
//! handlers dispatched through the real capability path; the one-shot fire →
//! `Completed` derivation is owned by `trigger_poller_e2e.rs` +
//! `repository_contract.rs` and is deliberately NOT re-covered here.

use super::reborn_support::group::{HarnessResult, RebornIntegrationGroup};
use super::reborn_support::reply::RebornScriptedReply;
use serde_json::json;

/// Far-future wall-clock so `trigger_create`'s `next_run_at` (computed against
/// the real `SystemTriggerManagementClock`) always has a future slot — no
/// wall-clock flake — and unambiguous (no DST edge).
const ONCE_AT: &str = "2999-01-01T00:00:00";
const TRIGGER_NAME: &str = "t0-triggers-once";

// TODO(T0-TRIGGERS, no enabler needed): distinct verb branches this same group
// can grow in a follow-up without any harness seam — add as new `scenario_*`
// files, not by bloating this happy-path lifecycle:
// - cron-schedule create (`{kind:"cron", expression, timezone}`) → list renders
// `is_recurring`/next_run_at; contrast with the Once path here.
// - `trigger_list` `limit`/`run_limit` params (bounded output).
// - deny/error branches through the capability path (model-recoverable, NOT
// terminal per `.claude/rules/agent-loop-capabilities.md`): remove/pause a
// non-existent `trigger_id` → `{"removed":false}` / `{"updated":false}`;
// malformed `trigger_id` → surfaced input error the model can retry.
//
// Two gotchas for follow-up scenarios in THIS group binary:
// - `tool_result_output(cap)` returns the MOST RECENT result for `cap` in the
// thread's slice. If a scenario dispatches the same verb twice in one thread,
// read the intermediate result before the second call — `.rev()` will
// otherwise silently return the later one.
// - the group's trigger repository is shared across scenarios with NO cleanup
// between them; keep list assertions id-scoped (`.any(|t| t["trigger_id"]…)`)
// and never assert an exact `triggers.len()`, which would flake on leftovers.
pub async fn run(g: &RebornIntegrationGroup) -> HarnessResult<()> {
// ── Thread A: create a one-shot Once trigger, then list it ───────────────
let creator = g
.thread("trigger-create")
.script([
RebornScriptedReply::tool_call(
"builtin.trigger_create",
json!({
"name": TRIGGER_NAME,
"prompt": "remind me once",
"schedule": {"kind": "once", "at": ONCE_AT, "timezone": "UTC"},
}),
),
RebornScriptedReply::tool_call("builtin.trigger_list", json!({})),
RebornScriptedReply::text("created"),
])
.build()
.await?;
creator.submit_turn("create a one-shot reminder").await?;
creator
.assert_tool_invoked("builtin.trigger_create")
.await?;
creator.assert_tool_invoked("builtin.trigger_list").await?;

// create output: once schedule, enabled + scheduled, server-minted id.
let created = creator.tool_result_output("builtin.trigger_create").await?;
let trigger = &created["trigger"];
let trigger_id = trigger["trigger_id"]
.as_str()
.ok_or("trigger_create output missing trigger_id")?
.to_string();
Comment thread
henrypark133 marked this conversation as resolved.
if trigger["schedule"]["kind"] != json!("once") {
return Err(format!("expected once schedule, got {}", trigger["schedule"]).into());
}
if trigger["state"] != json!("scheduled") || trigger["is_enabled"] != json!(true) {
return Err(format!("new trigger must be scheduled + enabled: {trigger}").into());
}

// list output: the just-created trigger is present by id AND name.
let listed = creator.tool_result_output("builtin.trigger_list").await?;
let in_list = listed["triggers"]
.as_array()
.ok_or("trigger_list output missing triggers array")?
.iter()
.any(|t| t["trigger_id"] == json!(trigger_id) && t["name"] == json!(TRIGGER_NAME));
if !in_list {
return Err(format!("created trigger absent from list: {listed}").into());
}

// ── Thread B: pause → resume → remove by id, over the SHARED repo ─────────
// A distinct conversation_id → distinct thread, but the trigger scope
// (tenant, user, agent, project) is identical, so thread B resolves thread
// A's trigger from the shared `HostRuntimeCapabilityHarness` repository.
let manager = g
.thread("trigger-manage")
.script([
RebornScriptedReply::tool_call(
"builtin.trigger_pause",
json!({"trigger_id": trigger_id}),
),
RebornScriptedReply::tool_call(
"builtin.trigger_resume",
json!({"trigger_id": trigger_id}),
),
RebornScriptedReply::tool_call(
"builtin.trigger_remove",
json!({"trigger_id": trigger_id}),
),
RebornScriptedReply::tool_call("builtin.trigger_list", json!({})),
RebornScriptedReply::text("managed"),
])
.build()
.await?;
manager
.submit_turn("pause, resume, then remove the reminder")
.await?;

// pause: found the cross-thread trigger and marked it paused.
let paused = manager.tool_result_output("builtin.trigger_pause").await?;
if paused["updated"] != json!(true)
|| paused["trigger"]["state"] != json!("paused")
|| paused["trigger"]["trigger_id"] != json!(trigger_id)
{
return Err(format!("pause must mark the trigger paused: {paused}").into());
}
// resume: state returns to scheduled.
let resumed = manager.tool_result_output("builtin.trigger_resume").await?;
if resumed["updated"] != json!(true)
|| resumed["trigger"]["state"] != json!("scheduled")
|| resumed["trigger"]["trigger_id"] != json!(trigger_id)
{
return Err(format!("resume must return the trigger to scheduled: {resumed}").into());
}
// remove: the trigger is deleted.
let removed = manager.tool_result_output("builtin.trigger_remove").await?;
if removed["removed"] != json!(true) || removed["trigger"]["trigger_id"] != json!(trigger_id) {
return Err(format!("remove must delete the trigger: {removed}").into());
}
// final list: the removed id is absent — non-vacuity guard proving remove
// really deleted it (not that the assertions pass unconditionally).
let after = manager.tool_result_output("builtin.trigger_list").await?;
let still_present = after["triggers"]
.as_array()
.ok_or("trigger_list output missing triggers array")?
.iter()
.any(|t| t["trigger_id"] == json!(trigger_id));
if still_present {
return Err(format!("removed trigger still present in list: {after}").into());
}

Ok(())
}
2 changes: 2 additions & 0 deletions tests/support/reborn/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ Richer assertions in `assertions.rs` (all check the `[baseline..]` delta per thr
- `assert_tool_result_contains(needle)` — a recorded capability result's output contains the text (proves the scripted body surfaced back to the model on the *Completed* path; reads the in-process recorder).
- `assert_tool_error(class, reason)` — a persisted `ToolResultReference` envelope's parsed `safe_summary` field is of outcome `class` (`ToolErrorClass::{Failed, Denied}`) and carries `reason`. Distinct from `assert_tool_result_contains`: this reads the *Failed*/*Denied* capability-error path (persisted via `append_tool_result_reference`), not the in-process recorder, so it's the assertion for `egress_error`-scripted responses and other capability failures/denials. `class` is a typed arg (not a needle prefix) so it discriminates Failed-vs-Denied structurally — a `Failed{PolicyDenied}` and a `Denied{policy_denied}` render the same `reason` token but different classes. Parses the `safe_summary` field (not a raw-JSON substring). Scans full thread history (not baseline-sliced) — safe only for single-turn harnesses today; a multi-turn/group reuse must add baseline scoping first.
- `assert_network_egress_header_contains(url_substr, header_name, value_substr)` — reads the **network** egress lane (`captured_network_requests()`), not the runtime lane the four assertions above read. Needed for `.with_github_issue_tools()`: that harness's `try_with_host_http_egress` overwrites the runtime port with the host egress pipeline over the network recorder, so the runtime-lane `assert_egress_*` family is inert for it — assert here instead.
- `tool_result_output(capability_id)` — the parsed JSON output of the most-recent recorded capability result for that id, for reading server-minted fields (e.g. `trigger_id`) a static script can't reference ahead of time.

### Keyed HTTP responses

Expand Down Expand Up @@ -371,6 +372,7 @@ pub async fn run(g: &RebornIntegrationGroup) -> HarnessResult<()> {
| `RebornIntegrationGroup::live_approvals()` | file tools (write_file/read_file @ Ask) | disabled |
| `RebornIntegrationGroup::builtin_tools()` | core built-in (http/echo/time/json/shell) | enabled |
| `RebornIntegrationGroup::extension_lifecycle()` | extension_search/install/activate/remove | enabled |
| `RebornIntegrationGroup::triggers()` | trigger_create/list/pause/resume/remove | enabled |
| `RebornIntegrationGroup::builder().storage(LibSql).live_approvals()` | same + LibSql storage | disabled |

### Key accessors on `RebornIntegrationGroup`
Expand Down
30 changes: 30 additions & 0 deletions tests/support/reborn/assertions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,4 +315,34 @@ impl RebornIntegrationHarness {
)
.into())
}

/// Return the parsed JSON `output` of the MOST RECENT recorded capability
/// result for `capability_id` (baseline-sliced to this thread's turns).
///
/// Unlike `assert_tool_result_contains`, this returns the value so a test can
/// read a server-minted field — e.g. the `trigger_id` a `builtin.trigger_create`
/// dispatch mints, which a static script cannot know ahead of time and which
/// later `trigger_pause`/`resume`/`remove` turns must reference. Errors (never
/// silently returns `Null`) when no result for `capability_id` was recorded.
pub async fn tool_result_output(
Comment thread
henrypark133 marked this conversation as resolved.
&self,
capability_id: &str,
) -> HarnessResult<serde_json::Value> {
let results = self.captured_capability_results();
if let Some(result) = results
.iter()
.rev()
.find(|result| result.capability_id.as_str() == capability_id)
{
return Ok(result.output.clone());
}
let seen: Vec<String> = results
.iter()
.map(|result| result.capability_id.as_str().to_string())
.collect();
Err(format!(
"no recorded capability result for {capability_id:?}; saw results for {seen:?}"
)
.into())
}
}
20 changes: 18 additions & 2 deletions tests/support/reborn/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,9 @@ impl GroupCapability {
/// runtime so state written by thread A is visible to thread B.
///
/// Construct with [`live_approvals`](Self::live_approvals),
/// [`builtin_tools`](Self::builtin_tools), or
/// [`extension_lifecycle`](Self::extension_lifecycle), or via
/// [`builtin_tools`](Self::builtin_tools),
/// [`extension_lifecycle`](Self::extension_lifecycle), or
/// [`triggers`](Self::triggers), or via
/// [`builder`](Self::builder) for custom storage mode.
pub struct RebornIntegrationGroup {
pub(crate) shared: Arc<GroupSharedStorage>,
Expand Down Expand Up @@ -311,6 +312,14 @@ impl RebornIntegrationGroup {
Self::builder().profile_tools().await
}

/// Group with trigger-management tools
/// (trigger_create/list/pause/resume/remove). Auto-approve is enabled for
/// all capability ids in the group scope so the `Ask`-mode verbs dispatch
/// through the real capability path instead of raising approval gates.
pub async fn triggers() -> HarnessResult<Self> {
Self::builder().triggers().await
}

/// Builder for advanced configuration (e.g. `StorageMode::LibSql`).
/// Defaults to `StorageMode::InMemory`.
pub fn builder() -> RebornIntegrationGroupBuilder {
Expand Down Expand Up @@ -690,6 +699,13 @@ impl RebornIntegrationGroupBuilder {
let capability = GroupCapability::HostRuntime(Arc::new(host_runtime));
self.into_group(base, capability).await
}

/// Build a trigger-management group. See [`RebornIntegrationGroup::triggers`].
pub async fn triggers(self) -> HarnessResult<RebornIntegrationGroup> {
let host_runtime = HostRuntimeCapabilityHarness::trigger_management_tools().await?;
let capability = GroupCapability::HostRuntime(Arc::new(host_runtime));
self.build_with_capability(capability).await
}
}

// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion tests/support/reborn/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1933,7 +1933,7 @@ impl HostRuntimeCapabilityHarness {
Ok(harness)
}

async fn trigger_management_tools() -> HarnessResult<Self> {
pub(crate) async fn trigger_management_tools() -> HarnessResult<Self> {
let harness = Self::new_with_options(
"reborn-e2e-trigger-management-tools",
vec![
Expand Down
Loading