From 11a337336ed2e18d1ea2ab4373c325543013b79f Mon Sep 17 00:00:00 2001 From: Henry Park Date: Tue, 30 Jun 2026 23:31:17 -0700 Subject: [PATCH 1/4] test(reborn): int-tier coverage for trigger-management verbs (T0-TRIGGERS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `reborn_group_triggers`, a group integration test that drives the five trigger-management verbs (trigger_create/list/pause/resume/remove) through the real agent-loop turn -> capability dispatch path — the only int-tier coverage of the pause/resume/remove/list handlers (composition-tier trigger_poller_e2e invokes only trigger_create directly, and pause/resume are dispatched through the real capability path nowhere else). The single scenario: - Thread A: create a one-shot `Once{at}` trigger + list it, reading back the server-minted `trigger_id` via a new `tool_result_output` accessor. - Thread B (different conversation, same trigger scope over the shared repo): pause -> resume -> remove by that id, then list to confirm removal — which also proves cross-thread trigger persistence. Deliberately does NOT re-cover the one-shot fire -> Completed derivation (owned by trigger_poller_e2e.rs + repository_contract.rs) or the trigger -> Slack outbound leg (owned by slack_host_beta.rs) — consolidate, don't proliferate. TODO blocks record the deferred triggered-turn coverage that needs an unmerged submit/auth-gate/outbound harness seam. Seams (test-support only; no production-crate changes): - `HostRuntimeCapabilityHarness::trigger_management_tools()` -> `pub(crate)`. - `RebornIntegrationGroup::triggers()` mirroring `extension_lifecycle()`. - `RebornIntegrationHarness::tool_result_output(capability_id)` to read a server-minted result field a static script cannot know ahead of time. Mutation-verified: resume->paused dispatch mutation and a no-op remove mutation each turn the relevant assertion RED for the right reason. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.toml | 4 + tests/reborn_group_triggers/main.rs | 63 ++++++++ .../scenario_verbs_lifecycle.rs | 148 ++++++++++++++++++ tests/support/reborn/CLAUDE.md | 1 + tests/support/reborn/assertions.rs | 30 ++++ tests/support/reborn/group.rs | 20 ++- tests/support/reborn/harness.rs | 2 +- 7 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 tests/reborn_group_triggers/main.rs create mode 100644 tests/reborn_group_triggers/scenario_verbs_lifecycle.rs diff --git a/Cargo.toml b/Cargo.toml index effea31ee92..31f1d74fa26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] diff --git a/tests/reborn_group_triggers/main.rs b/tests/reborn_group_triggers/main.rs new file mode 100644 index 00000000000..f6a470e8293 --- /dev/null +++ b/tests/reborn_group_triggers/main.rs @@ -0,0 +1,63 @@ +//! 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() { + 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(); +} diff --git a/tests/reborn_group_triggers/scenario_verbs_lifecycle.rs b/tests/reborn_group_triggers/scenario_verbs_lifecycle.rs new file mode 100644 index 00000000000..c51c22e99cf --- /dev/null +++ b/tests/reborn_group_triggers/scenario_verbs_lifecycle.rs @@ -0,0 +1,148 @@ +//! 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(); + 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") { + 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") { + 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) { + 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(()) +} diff --git a/tests/support/reborn/CLAUDE.md b/tests/support/reborn/CLAUDE.md index 9c4555fe214..d019c6305b0 100644 --- a/tests/support/reborn/CLAUDE.md +++ b/tests/support/reborn/CLAUDE.md @@ -371,6 +371,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` diff --git a/tests/support/reborn/assertions.rs b/tests/support/reborn/assertions.rs index 7b766ff0b58..0ca21fb00b7 100644 --- a/tests/support/reborn/assertions.rs +++ b/tests/support/reborn/assertions.rs @@ -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( + &self, + capability_id: &str, + ) -> HarnessResult { + 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 = 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()) + } } diff --git a/tests/support/reborn/group.rs b/tests/support/reborn/group.rs index 52af76f5295..b2249731b47 100644 --- a/tests/support/reborn/group.rs +++ b/tests/support/reborn/group.rs @@ -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, @@ -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::builder().triggers().await + } + /// Builder for advanced configuration (e.g. `StorageMode::LibSql`). /// Defaults to `StorageMode::InMemory`. pub fn builder() -> RebornIntegrationGroupBuilder { @@ -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 { + let host_runtime = HostRuntimeCapabilityHarness::trigger_management_tools().await?; + let capability = GroupCapability::HostRuntime(Arc::new(host_runtime)); + self.build_with_capability(capability).await + } } // --------------------------------------------------------------------------- diff --git a/tests/support/reborn/harness.rs b/tests/support/reborn/harness.rs index acb3e1bf8e7..fab7a403272 100644 --- a/tests/support/reborn/harness.rs +++ b/tests/support/reborn/harness.rs @@ -1933,7 +1933,7 @@ impl HostRuntimeCapabilityHarness { Ok(harness) } - async fn trigger_management_tools() -> HarnessResult { + pub(crate) async fn trigger_management_tools() -> HarnessResult { let harness = Self::new_with_options( "reborn-e2e-trigger-management-tools", vec![ From 6ed46b4a90d48c057b55020ab2214e5b8e68283c Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 1 Jul 2026 08:18:58 -0700 Subject: [PATCH 2/4] style(reborn): cargo fmt tests/reborn_group_triggers CI flagged unformatted lines (method-chain wrap width) in the new files. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/reborn_group_triggers/main.rs | 5 +---- tests/reborn_group_triggers/scenario_verbs_lifecycle.rs | 4 +++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/reborn_group_triggers/main.rs b/tests/reborn_group_triggers/main.rs index f6a470e8293..4a846e7ddbc 100644 --- a/tests/reborn_group_triggers/main.rs +++ b/tests/reborn_group_triggers/main.rs @@ -36,10 +36,7 @@ async fn triggers_group() { // 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, - ); + 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 diff --git a/tests/reborn_group_triggers/scenario_verbs_lifecycle.rs b/tests/reborn_group_triggers/scenario_verbs_lifecycle.rs index c51c22e99cf..fed1a28c77b 100644 --- a/tests/reborn_group_triggers/scenario_verbs_lifecycle.rs +++ b/tests/reborn_group_triggers/scenario_verbs_lifecycle.rs @@ -61,7 +61,9 @@ pub async fn run(g: &RebornIntegrationGroup) -> HarnessResult<()> { .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_create") + .await?; creator.assert_tool_invoked("builtin.trigger_list").await?; // create output: once schedule, enabled + scheduled, server-minted id. From dfac3a9964a6bebdc5d7ab09008fc2de1a7e1eeb Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 1 Jul 2026 10:16:35 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix(reborn):=20address=20PR=20#5482=20revie?= =?UTF-8?q?w=20=E2=80=94=20entrypoint=20naming=20+=20doc=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `triggers_group` -> `triggers_group_e2e` to match the sibling group-test entrypoint convention (memory_group_e2e, extensions_group_e2e, approvals_group_e2e), so `_group_e2e`-scoped filters find it. - Add `tool_result_output` to the CLAUDE.md richer-assertions index; it was added to assertions.rs but never indexed alongside its siblings. The Copilot review comment on this PR (trigger_id String reuse across multiple json! calls) is not applicable: serde_json's json! macro expands `json!($other)` to `to_value(&$other)` (borrows, never moves) for arbitrary expressions, so the value is never consumed and no clone is needed — the existing 5x reuse already compiles clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/reborn_group_triggers/main.rs | 2 +- tests/support/reborn/CLAUDE.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/reborn_group_triggers/main.rs b/tests/reborn_group_triggers/main.rs index 4a846e7ddbc..578e33f8f9c 100644 --- a/tests/reborn_group_triggers/main.rs +++ b/tests/reborn_group_triggers/main.rs @@ -28,7 +28,7 @@ mod scenario_verbs_lifecycle; use reborn_support::group::{RebornIntegrationGroup, ScenarioReport}; #[tokio::test] -async fn triggers_group() { +async fn triggers_group_e2e() { let g = RebornIntegrationGroup::triggers() .await .expect("group builds"); diff --git a/tests/support/reborn/CLAUDE.md b/tests/support/reborn/CLAUDE.md index d019c6305b0..b7b01e3fd7e 100644 --- a/tests/support/reborn/CLAUDE.md +++ b/tests/support/reborn/CLAUDE.md @@ -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 From eab194eba4e5d387eea07ba2feb4454af73fc1bd Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 1 Jul 2026 13:03:14 -0700 Subject: [PATCH 4/4] test(reborn): assert operated trigger_id in pause/resume/remove (PR #5482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: the pause/resume/remove assertions checked the returned `state`/`removed` flag but not that the returned `trigger` is the same `trigger_id` thread A created. In a shared/dirty repo that could mask a `set_scoped_trigger_state`/`remove_scoped_trigger` bug touching the wrong record while still returning updated/removed=true. Fold a `["trigger"]["trigger_id"] == trigger_id` check into each verb's condition — matches this file's own "keep list assertions id-scoped" guidance. Mutation-verified: pointing the pause check at a bogus id turns it RED for the right reason (real returned id != bogus), then reverted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scenario_verbs_lifecycle.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/reborn_group_triggers/scenario_verbs_lifecycle.rs b/tests/reborn_group_triggers/scenario_verbs_lifecycle.rs index fed1a28c77b..65599fc58c0 100644 --- a/tests/reborn_group_triggers/scenario_verbs_lifecycle.rs +++ b/tests/reborn_group_triggers/scenario_verbs_lifecycle.rs @@ -121,17 +121,23 @@ pub async fn run(g: &RebornIntegrationGroup) -> HarnessResult<()> { // 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") { + 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") { + 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) { + 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