Repository navigation
test(reborn): int-tier coverage for trigger-management verbs (T0-TRIGGERS) #5482
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
11a3373
test(reborn): int-tier coverage for trigger-management verbs (T0-TRIG…
henrypark133 6ed46b4
style(reborn): cargo fmt tests/reborn_group_triggers
henrypark133 dfac3a9
fix(reborn): address PR #5482 review — entrypoint naming + doc gap
henrypark133 eab194e
test(reborn): assert operated trigger_id in pause/resume/remove (PR #…
henrypark133 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
156
tests/reborn_group_triggers/scenario_verbs_lifecycle.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| 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(()) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.