Files
ironclaw/tests/engine_v2_gate_integration.rs
Illia Polosukhin f37a26f75a fix(engine): mission cron scheduling + timezone propagation (#1944) (#1957)
* fix(engine): compute next_fire_at for cron missions (#1944)

MissionCadence::Cron missions never fired automatically because
next_fire_at was initialized to None and never computed from the cron
expression. The ticker checked next_fire_at <= now which was always
false.

- Add next_cron_fire() helper that parses cron expressions (5/6/7-field)
  and computes the next fire time, with optional timezone support
- Compute next_fire_at in create_mission() for Cron cadence
- Advance next_fire_at in fire_mission() after each successful fire
- Recompute next_fire_at in resume_mission() for stale cron missions
- Add regression tests covering create, fire, tick, and resume flows

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(engine): propagate user timezone to missions and CodeAct scripts (#1944)

The LLM had to ask users for timezone because it wasn't available in
context. Now:

- Add user_timezone to ThreadExecutionContext (from thread metadata)
- Store user timezone in thread metadata when received from channel
- Auto-inject timezone into mission_create cron cadence from context
- Expose user_timezone as a Monty/CodeAct context variable
- Document user_timezone in the CodeAct preamble prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: introduce ValidTimezone strict type in ironclaw_common (#1944)

Address PR review feedback: timezone strings were stored and propagated
without validation. Now:

- Add ValidTimezone newtype in ironclaw_common that validates IANA
  timezone strings at construction (parse returns None for empty/invalid)
- MissionCadence::Cron.timezone is now Option<ValidTimezone>
- ThreadExecutionContext.user_timezone is now Option<ValidTimezone>
- Bridge router validates timezone before storing in thread metadata
- next_cron_fire() takes Option<&ValidTimezone> — no silent fallback
- CodeAct scripting validates on read, falls back to "UTC" for missing
- Fix orchestrator doc comment (bridge router, not ConversationManager)
- Tighten test assertion to require strictly future next_fire_at
- Add ValidTimezone unit tests (parse, serde roundtrip, empty/invalid)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove clone_on_copy for ValidTimezone (clippy)

ValidTimezone is Copy, so .clone() is unnecessary. Clippy CI caught this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: lenient timezone deserialization + bootstrap backfill (#1944)

Address second round of PR review feedback:

- Add deserialize_option_lenient() in ironclaw_common so persisted
  missions with invalid timezone strings deserialize as None instead
  of failing the whole record
- Apply lenient deserializer to MissionCadence::Cron.timezone field
- Backfill next_fire_at in bootstrap_project() for legacy cron missions
  that predate the scheduling fix (next_fire_at was None)
- Remove unused chrono-tz direct dep from ironclaw_engine (now via
  ironclaw_common)
- Add tests for lenient deserialization (valid, invalid, null, missing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: collapse nested if in bootstrap_project (clippy)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): address PR review — pre-spawn timezone, update_mission scheduling, polish

Addresses 6 unanswered review comments on #1957:

- (high, serrrfirat) router.rs: user_timezone was set after start_thread,
  so the executor's in-memory thread never saw it on the first turn.
  Threaded user_timezone through handle_user_message ->
  spawn_thread_with_history via a new initial_metadata param so it lands
  on the thread before the background task starts. source_channel is
  routed through the same path (had the same latent bug).

- (high, serrrfirat / Copilot) update_mission: Manual -> Cron left
  next_fire_at = None and the mission stayed inert; cron expression
  edits kept firing on the old schedule. update_mission now recomputes
  next_fire_at for Cron and clears it for non-cron cadences.

- (low, Copilot) parse_cadence: dropped trimmed.contains(' ') so cron
  expressions with tab/newline separators are detected.

- (medium, Copilot) bootstrap_project: backfill save_mission failure now
  logs at debug! instead of being silently swallowed.

- (low, Copilot) codeact_preamble: cron timezone is a default, not
  automatic — explicit timezone param overrides.

Plus self-review polish:
- normalize_cron_expression rejects 4-field (and other off-count) input
  up front with a clear error instead of falling through to the cron
  parser.
- fire_mission has a comment explaining the catch-up semantics
  (next_fire_at recomputed from now(), missed windows coalesced).
- New unit tests for next_cron_fire with an explicit America/New_York
  timezone, plus 3 update_mission regression tests for Manual->Cron,
  Cron->Manual, and cron expression change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): DST/timezone tests, InvalidCadence error, lenient drop log

Addresses the latest review on #1957:

- Add EngineError::InvalidCadence and switch next_cron_fire to use it.
  Cron parse failures are validation errors, not store errors — callers
  can now map them to user-facing messages without misclassification.

- Add DST regression tests in types/mission.rs covering the two tricky
  cases the PR exists to enable:
    * spring-forward gap (30 2 * * * on a missing-local-hour day must
      not land in the [02:00, 03:00) window)
    * fall-back overlap (30 1 * * * on a doubled-hour day must
      consistently round-trip)
    * 30-day window straddling spring-forward must contain both
      13:00 and 14:00 UTC fires for an "9am NY" schedule
  Plus normalize_seven_field_cron and an InvalidCadence error path test.

- Add a tz-positive test in runtime/mission.rs that creates a cron
  mission with America/New_York and asserts the resulting next_fire_at
  lands at UTC 13/14 (NY 09:00) rather than UTC 09 — exercising the
  full MissionManager → next_cron_fire chain end-to-end.

- ironclaw_common::deserialize_option_lenient now logs at debug! when
  it drops an invalid IANA timezone string to None, so a typo in fresh
  user config is observable in logs even though the record loads.
  Adds tracing as a direct dep of ironclaw_common.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): suppress no-panics check on DST test helper

The check_no_panics.py CI script has a pre-existing lexer bug where a
lifetime apostrophe in this file (`Formatter<'_>` on line 38) puts its
char-state lexer into character mode permanently, breaking brace
tracking and so failing to detect that the new `schedule_after` test
helper lives inside `#[cfg(test)] mod tests`. The script normally only
checks added lines so the latent bug is invisible — my new helper
exposed it.

Use the script's documented `// safety:` per-line escape hatch to
suppress the false positives. The helper is unambiguously a test-only
helper and the panics are intentional in test context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): log all next_cron_fire failure modes in bootstrap backfill

PR review (Copilot): bootstrap_project only patched the legacy mission
when next_cron_fire returned Ok(Some(next)). The Ok(None) case (cron
expression with no upcoming fire times — e.g. a year-restricted
expression in the past) and the Err(_) case (invalid expression) both
fell through silently, leaving the mission Active with next_fire_at =
None and no log signal — exactly the silent-stuck-mission scenario
this PR is supposed to prevent.

Match all three branches and emit a debug! log on each path so an
operator can see which legacy missions need attention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): close resume_mission TOCTOU window and stop fire_mission from orphaning threads

Two medium-severity issues from PR review (serrrfirat):

1. **resume_mission TOCTOU.** The previous flow was
   `update_mission_status(Active)` followed by a separate `load + mutate
   next_fire_at + save` round-trip — leaving an extra interleave window
   where a concurrent `update_mission`/`fire_mission` write could be
   silently clobbered by the second save's stale reload. Use the mission
   already loaded for the ownership check, set `status = Active` and
   `next_fire_at`, and do a single `save_mission`.

2. **fire_mission orphan thread.** The thread was spawned, then
   `next_cron_fire(...)?` and `save_mission(...)?` ran. Both could
   propagate Err *after* the thread was already running, leaving:
   - no entry in `thread_history`
   - `threads_today` not incremented (budget bypassed)
   - no outcome watcher installed

   Two narrow fixes:
   - Install `spawn_mission_outcome_watcher` *before* `save_mission`.
     The watcher only depends on `thread_id` (it joins via
     ThreadManager and reloads the mission record itself), so a
     transient store error no longer abandons the running thread.
   - Replace `next_cron_fire(...)?` with match-and-log: a parse error
     on a corrupt persisted expression now preserves the existing
     `next_fire_at` and emits a `debug!` instead of aborting fire and
     unwinding past the spawn.

Regression tests:
- `fire_mission_with_corrupt_cron_expression_does_not_orphan_thread`
- `resume_mission_preserves_concurrent_field_changes`

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): Paranoid Architect review — orphan/re-fire, TOCTOU, cron docs, resume tz

Addresses 4 of 5 findings from serrrfirat's Paranoid Architect review on
#1957 (the 5th is declined with rationale in the PR thread).

1. (HIGH) fire_mission save_mission failure non-fatal + tick() per-mission
   isolation. The previous code propagated `save_mission(...)?` after the
   thread was spawned, leaving an orphan thread *and* — for cron cadences —
   leaving next_fire_at un-advanced, causing the next tick to re-fire the
   same mission in a runaway loop. Now save is best-effort: log a `debug!`
   on failure and still return Ok(Some(thread_id)). The watcher is already
   installed (from the earlier round). Separately, tick() now catches
   per-mission load and fire failures with match-and-continue so a single
   bad mission doesn't abort the entire tick cycle.

2. (MED) bootstrap_project backfill TOCTOU: previously list_all_missions
   then a deferred save_mission could clobber concurrent writes with the
   stale snapshot. Now re-load the mission immediately before save and
   only patch if next_fire_at is still None — narrows the window
   significantly without adding a new Store trait method. Residual race
   documented inline.

3. (MED) 6-field cron ambiguity: the `0 9 * * * 2027` form could be read
   as either "sec min hr dom mon dow" (our normalizer's interpretation,
   matching the `cron` crate's native format) or Quartz-style "min hr dom
   mon dow year". Documented the assumed format in the doc comment, added
   a regression test pinning the interpretation, and noted it in the
   CodeAct preamble so users know to use the explicit 7-field form for
   year-bounded schedules.

4. (MED) user_timezone propagation on inject/resume: only set on new
   thread spawn before. Now the resume path writes the fresh tz to
   thread metadata before resume_thread() reloads from store, so the
   resumed execution sees the up-to-date value. The inject path is
   documented as a known limitation — updating live in-memory state in
   a running ExecutionLoop requires a new signal type (out of scope).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): close latent next_fire_at bypass in ensure_* mission helpers

PR review (serrrfirat): two private mission-creation helpers built a
Mission via `Mission::new() + save_mission()` directly, skipping the
`next_fire_at` computation that `create_mission` performs. Today every
caller passes `OnSystemEvent` so the bug never bites — but a future
caller passing `Cron` would silently re-introduce the original
`next_fire_at = None` bug that #1944 fixes, and the bootstrap backfill
wouldn't help (it only triggers for Active+Cron with `next_fire_at =
None` *after* a process restart).

Add the same Cron-cadence guard to both helpers:
- `ensure_self_improvement_mission`
- `ensure_mission_by_metadata`

Regression test `ensure_mission_by_metadata_with_cron_cadence_computes_next_fire_at`
exercises the cron path through the private helper and asserts the
computed `next_fire_at` is set and in the future.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): final PR #1957 review pass — Ok(None) cron + tick cooldown

Addresses the remaining open review threads on PR #1957:

1. types/mission.rs:391 — DST test reference comment now matches the
   actual `2027-03-13 00:00 UTC` anchor instead of the stale `22:00 UTC`
   wording (Copilot 3050772614 / 3055762609).

2. runtime/manager.rs::set_thread_metadata — was best-effort and silently
   swallowed store errors, while the conversation.rs:250 caller comment
   claimed the resumed thread was guaranteed to see the new value. Now
   returns Result<(), EngineError>; conversation.rs logs on failure and
   the comment is honest about the contract (Copilot 3051471863 +
   serrrfirat 3056444137).

3. types/mission.rs::next_cron_fire_required — new helper that maps
   Ok(None) to EngineError::InvalidCadence. Used by create_mission,
   update_mission cadence-change, resume_mission, and the two
   ensure_*_mission helpers. fire_mission and bootstrap_project keep
   the existing grace (logged) since the thread/data is already in
   flight (Copilot 3051471912/47/85 + serrrfirat 3056443657).

4. runtime/mission.rs::tick — when save_mission fails after a successful
   spawn, the persisted next_fire_at stays in the past and every
   subsequent 60s tick re-fires the same mission, spawning duplicates
   up to the daily budget. Added an in-memory `last_fire_attempt` map
   armed by fire_mission (regardless of save outcome) and consulted by
   tick to enforce a 90s per-mission cooldown (serrrfirat 3056443083).

Regression tests:
 - create_mission_rejects_unschedulable_cron
 - update_mission_rejects_switch_to_unschedulable_cron
 - resume_mission_rejects_unschedulable_cron
 - tick_cooldown_suppresses_re_fire_on_save_failure

All four use a 7-field year-locked cron (`0 0 0 1 1 * 2020`) to exercise
the Ok(None) path deterministically. Mission test count: 49 → 53.

Verified: cargo fmt clean, cargo clippy --all-targets --all-features
zero warnings, full `cargo test` green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): prune fire cooldown map + reject Quartz-style 6-field cron

Addresses two follow-up review threads on PR #1957:

1. `last_fire_attempt` was insert-only — completed/paused missions left
   stale entries that accumulated over the process lifetime. Now:
   - `pause_mission` and `complete_mission` drop the entry explicitly.
   - `tick` opportunistically prunes any entry whose cooldown window has
     elapsed, catching stragglers from non-graceful transitions (crash
     recovery, direct store edits) so the map can never grow unbounded.
   Regression test: `pause_and_complete_drop_cooldown_entry`.
   (serrrfirat 3057568698)

2. 6-field cron with a year-shaped trailing field (e.g. `0 9 * * * 2027`)
   was silently misinterpreted as `sec min hr dom mon dow=2027` instead
   of the Quartz-style "9am daily in 2027" the caller almost certainly
   meant. `normalize_cron_expression` now rejects this pattern with a
   clear `InvalidCadence` error pointing at the explicit 7-field form
   (`0 0 9 * * * 2027`). The 4-digit year heuristic is bounded to
   1970-2099 so out-of-range numerics fall through unchanged. The
   existing pinning test for `* `-terminated 6-field input is unaffected.
   Regression test: `six_field_cron_with_year_shaped_last_field_is_rejected`.
   (serrrfirat 3057569413)

Verified: cargo fmt clean, cargo clippy --all-targets --all-features
zero warnings, full `cargo test` green (308 engine tests pass).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(bridge,engine): parse_cadence prefix ordering + year-stable cron tests

Addresses three Copilot review threads on PR #1957:

1. `parse_cadence` (src/bridge/effect_adapter.rs) checked the cron
   heuristic (`split_whitespace().count() >= 5`) BEFORE the explicit
   `event:` / `webhook:` prefixes. An input like `event: a b c d e`
   silently became a `Cron` cadence with a parse-error downstream rather
   than the `OnEvent` the user requested. Reordered to check explicit
   prefixes first, falling through to cron only when none match.
   Regression test `parse_cadence_event_prefix_with_multi_token_pattern`
   covers `event:` + `webhook:` + a real cron control case.
   (Copilot 3057828107)

2. `next_cron_fire_respects_timezone` asserted `in_ny.year() >= 2026`,
   which is time-dependent (fails before 2026, tautology after). Replaced
   with `assert!(in_ny > Utc::now())` so the test stays stable across
   calendar years. (Copilot 3057828177)

3. `update_mission_cron_expression_change_recomputes_next_fire_at` used
   `0 0 1 1 *` ("once a year on Jan 1") as `before` and asserted
   `after < before`. Race around New Year's: the yearly schedule's next
   fire could land within seconds and invert the ordering. Switched to a
   year-locked 7-field cron (`0 0 0 1 1 * 2099`) so the next fire is
   deterministically far in the future regardless of run date.
   (Copilot 3057828212)

Verified: cargo fmt clean, cargo clippy --all-targets --all-features
zero warnings, full `cargo test --lib` green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): outcome processor reconciles fire accounting on save failure

Addresses Copilot review thread on PR #1957:

When `fire_mission`'s post-spawn `save_mission` fails, the persisted
mission is left missing the new `thread_id` (in `thread_history`), the
`threads_today` increment, the `last_fire_at` stamp, and — for cron
cadences — the advanced `next_fire_at`. The in-memory `last_fire_attempt`
cooldown holds the runaway re-fire path closed for ~90 s, but once it
elapses tick would re-fire against the still-stale persisted state.
Worse, when the spawned thread later completes, the outcome watcher
loaded the **stale** mission, mutated only `approach_history`/`status`,
and saved — permanently overwriting any chance to record the missing
fields.

`process_mission_outcome_and_notify` now reconciles those fields the
first time it sees a `thread_id` that isn't in `thread_history`:

  - Append `thread_id` to `thread_history`
  - Bump `threads_today` (saturating)
  - Stamp `last_fire_at = now` as a conservative approximation
  - For cron missions, recompute `next_fire_at` if it's None-or-past

The reconcile is idempotent: a replay with the same `thread_id` is a
no-op. Achieves eventual consistency for transient store failures even
after retries are exhausted, and handles the permanent-failure case
that a save-side retry loop alone could not.

Regression test: `outcome_processor_reconciles_missing_fire_accounting`
covers both the first-time reconcile path (history append, budget bump,
last_fire_at stamp, cron next_fire_at advance) and idempotent replay
(no double-count, no duplicate history entry).

Verified: cargo fmt clean, cargo clippy --all-targets --all-features
zero warnings, full `cargo test --lib` green, ironclaw_engine 87/87
mission tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): cooldown only on save failure + reconcile to original fire instant

Self-review pass on the #1944 work — addresses three findings from a fresh
read of the cooldown / reconcile interaction:

1. **Tick cooldown was throttling high-frequency cron schedules.** The
   in-memory `last_fire_attempt` map was checked unconditionally, with
   the 90 s cooldown enforced even for normally-firing cron missions.
   For `* * * * *` (every minute) the 60 s tick interval falls inside
   the 90 s window, so half the events were silently dropped after every
   successful fire. The cooldown was only ever needed to detect a
   `save_mission` failure, not to enforce a global rate floor.

   Fix: `fire_mission` now uses a single `fire_instant` for both the
   persisted `Mission.last_fire_at` and the in-memory cooldown entry.
   Tick arms the cooldown only when the persisted `last_fire_at`
   does NOT equal the in-memory value — the equality is the proof
   that `save_mission` succeeded. On the success path the two match
   and the cooldown is transparent regardless of cron frequency. On
   the failure path the persisted side still holds the OLD value
   (or `None`), the mismatch fires, and re-fire is suppressed until
   reconcile.

   Regression test:
   `tick_does_not_throttle_high_frequency_cron_after_successful_fire`
   creates a `* * * * *` mission, fires it, advances `next_fire_at`
   into the past WITHOUT clobbering `last_fire_at`, calls tick, and
   asserts a new thread is spawned. The pre-existing failure-mode
   test was updated to explicitly clobber `last_fire_at` so it
   continues to model the failed-save state under the new logic.

2. **Reconcile drifted `last_fire_at` to the outcome time.** When
   `process_mission_outcome_and_notify` reconciled fields after a
   failed save, it stamped `last_fire_at = now`, which can be many
   seconds (or hours, for long-running mission threads) later than the
   actual fire instant. For users with a configured `cooldown_secs`,
   that drift gradually extended the cooldown window beyond what the
   user asked for.

   Fix: thread the original `fire_instant` from `fire_mission` through
   `spawn_mission_outcome_watcher` and `process_mission_outcome_and_notify`
   as `original_fire_at: Option<DateTime<Utc>>`. The reconcile path
   uses it to set `last_fire_at` back to the moment of the original
   spawn, falling back to `now` only when the watcher path is unknown
   (test helpers, callers without an original instant). The watcher's
   `original_fire_at` ALSO matches the in-memory `last_fire_attempt[mid]`
   value, so the cooldown's mismatch detector resolves immediately
   after reconcile — even before the 90 s window elapses.

   Regression test: `outcome_processor_reconciles_missing_fire_accounting`
   now passes an explicit `original_fire_at` and asserts the
   reconciled `last_fire_at` equals that exact instant (not `now`).

3. **Reconcile bypassed `mission.record_thread`.** It pushed directly
   into `thread_history` and missed the `updated_at` bump.
   `process_mission_outcome_and_notify` re-stamps `updated_at` later
   so there was no functional impact, but two paths diverging on the
   same field-mutation pattern is a future-bug invitation.

   Fix: use `mission.record_thread(thread_id)` in the reconcile branch
   for parity with `fire_mission`.

Polish:
 - Documented the lenient `next_cron_fire` choice at all three call
   sites (`bootstrap_project` backfill, `fire_mission` post-spawn
   advance, reconcile path) to make the lenient/strict split easy to
   spot during review.
 - Added a clarifying comment on `update_mission`'s save-after-validate
   sequencing — `save_mission` is the only persistence boundary in the
   function, so `next_cron_fire_required`'s `Err` leaves the store
   untouched even though the in-memory `mission` was already mutated.

Verified: cargo fmt clean, cargo clippy --all-targets --all-features
zero warnings, full `cargo test --lib` green, ironclaw_engine 88/88
mission tests pass (87 → 88).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): close cooldown race + corrupt-cron runaway

Self-review pass on the cooldown rework — addresses the two failure
modes a fresh read of the success/failure detection turned up plus a
documentation gap on the equality check.

1. **TOCTOU race between save_mission success and cooldown insert.**
   The previous order was `save_mission(...)` → `last_fire_attempt.insert(...)`.
   A concurrent tick observing the gap saw the freshly-persisted
   `last_fire_at = fire_instant` AND no in-memory entry, evaluated the
   mismatch check to false, and (if `next_fire_at` was still in the past
   from any other code path) re-fired immediately.

   Fix: insert into `last_fire_attempt` BEFORE calling `save_mission`.
   While save is in flight, the in-memory map has `fire_instant` and
   the persisted record still has the OLD `last_fire_at`, so a concurrent
   tick sees a mismatch and arms the cooldown. Once save lands the
   values match (success) or stay mismatched (failure) — both correct.

   Regression test: `fire_mission_arms_cooldown_before_save_mission`.
   Adds an optional save-mission gate to `TestStore` (via oneshot
   channel + Notify), spawns `fire_mission` in a task, waits for save
   to enter the gate, and asserts `last_fire_attempt[mid]` is already
   populated while save is parked.

2. **Corrupted cron expression bypassed the cooldown.** When
   `next_cron_fire(expression)` returned `Err` (corrupt persisted
   expression), the previous code stamped `last_fire_at = fire_instant`
   anyway. Save then succeeded with `last_fire_at` matching the
   in-memory value, so tick's mismatch detector saw "save succeeded"
   and the cooldown was never armed. With `next_fire_at` still in the
   past (preserved because the cron crate couldn't compute a new one),
   every tick re-fired the same mission until `max_threads_per_day`
   was exhausted — same root cause shape as #1944.

   Fix: track `cron_advanced: bool` in `fire_mission`. When the cron
   advance fails, deliberately leave `last_fire_at` at its OLD value.
   The in-memory `last_fire_attempt[mid]` is still set to `fire_instant`,
   so the in-memory vs persisted mismatch arms the cooldown via the
   exact same code path as a save failure — no new signal needed.
   After 90 s the cooldown elapses and tick can retry, bounded by
   `max_threads_per_day`, in case the corruption resolves.

   Regression test: `tick_does_not_re_fire_corrupted_cron_within_cooldown_window`.
   Creates a cron mission, corrupts the persisted expression, sets
   `next_fire_at` to the past, fires once, then asserts a subsequent
   tick returns no new spawn AND that the persisted `last_fire_at`
   was deliberately left unset (proving the mismatch-arming path).

3. **Documented the equality check's precision requirement.** The
   `mission.last_fire_at != Some(*in_mem_last)` comparison is
   load-bearing and assumes the `Store` round-trips `DateTime<Utc>`
   without precision loss. The bridge's in-memory cache and JSON
   persistence both preserve nanoseconds; a future Postgres-backed
   store using `TIMESTAMPTZ` would silently break the success-path
   detection (microsecond truncation). Added a long comment at the
   tick check pointing future store implementers at the requirement
   so the next backend addition can either preserve precision or
   relax the comparison to "within one microsecond" before landing.

Verified: cargo fmt clean, cargo clippy --all-targets --all-features
zero warnings, full `cargo test --lib` green, ironclaw_engine 90/90
mission tests pass (88 → 90).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:49:36 +09:00

1838 lines
58 KiB
Rust

//! Integration tests for the unified ExecutionGate abstraction.
//!
//! Exercises the complete gate lifecycle:
//! 1. Tool call triggers GatePaused (approval or auth)
//! 2. Thread transitions to Waiting state
//! 3. PendingGateStore holds the gate with channel verification
//! 4. resolve_gate() resumes or stops the thread
//! 5. Cross-channel attacks are blocked structurally
//!
//! Uses the same ScriptedLlm + mock EffectExecutor pattern as
//! engine_v2_skill_codeact.rs.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use chrono::Utc;
use tokio::sync::RwLock;
use ironclaw_engine::types::capability::{EffectType, LeaseId};
use ironclaw_engine::{
ActionDef, ActionResult, Capability, CapabilityLease, CapabilityRegistry, DocId,
EffectExecutor, EngineError, GrantedActions, LeaseManager, LlmBackend, LlmCallConfig,
LlmOutput, LlmResponse, MemoryDoc, Mission, MissionId, MissionStatus, PolicyEngine, Project,
ProjectId, ResumeKind, Step, Store, Thread, ThreadConfig, ThreadEvent, ThreadId, ThreadManager,
ThreadMessage, ThreadOutcome, ThreadState, ThreadType, TokenUsage,
};
use ironclaw::bridge::EffectBridgeAdapter;
use ironclaw::context::JobContext;
use ironclaw::gate::pending::{PendingGate, PendingGateKey};
use ironclaw::gate::store::{GateStoreError, PendingGateStore, TRUSTED_GATE_CHANNELS};
use ironclaw::hooks::HookRegistry;
use ironclaw::tools::{ApprovalRequirement, Tool, ToolError, ToolOutput, ToolRegistry};
use ironclaw_safety::{SafetyConfig, SafetyLayer};
// ── Scripted LLM ─────────────────────────────────────────────
struct ScriptedLlm {
responses: std::sync::Mutex<Vec<LlmOutput>>,
}
impl ScriptedLlm {
fn new(responses: Vec<LlmOutput>) -> Arc<Self> {
Arc::new(Self {
responses: std::sync::Mutex::new(responses),
})
}
}
#[async_trait::async_trait]
impl LlmBackend for ScriptedLlm {
async fn complete(
&self,
_messages: &[ThreadMessage],
_actions: &[ActionDef],
_config: &LlmCallConfig,
) -> Result<LlmOutput, EngineError> {
let mut queue = self.responses.lock().unwrap();
if queue.is_empty() {
Ok(LlmOutput {
response: LlmResponse::Text("done".into()),
usage: TokenUsage::default(),
})
} else {
Ok(queue.remove(0))
}
}
fn model_name(&self) -> &str {
"scripted-mock"
}
}
// ── Gate-Aware Mock Effects ──────────────────────────────────
/// Mock EffectExecutor that returns GatePaused for specific tools,
/// NeedApproval for others, and success for the rest.
struct GateMockEffects {
/// Tools that trigger GatePaused with Approval resume kind.
gate_approval_tools: Vec<String>,
/// Tools that trigger GatePaused with Authentication resume kind.
gate_auth_tools: Vec<String>,
/// Tools that require approval first, then authentication on retry.
chained_approval_then_auth_tools: Vec<String>,
/// Recorded calls (including gated ones that were retried after approval).
calls: RwLock<Vec<(String, serde_json::Value)>>,
/// Actions cleared through the approval gate.
approved: RwLock<std::collections::HashSet<String>>,
/// Actions cleared through the auth gate.
authenticated: RwLock<std::collections::HashSet<String>>,
}
struct ApprovalTool;
#[async_trait]
impl Tool for ApprovalTool {
fn name(&self) -> &str {
"approval_test"
}
fn description(&self) -> &str {
"Integration test approval tool"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"value": { "type": "string" }
}
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::success(
serde_json::json!({"ok": true, "params": params}),
Duration::from_millis(1),
))
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
}
impl GateMockEffects {
fn new(gate_approval_tools: Vec<String>, gate_auth_tools: Vec<String>) -> Arc<Self> {
Self::new_with_chain(gate_approval_tools, gate_auth_tools, Vec::new())
}
fn new_with_chain(
gate_approval_tools: Vec<String>,
gate_auth_tools: Vec<String>,
chained_approval_then_auth_tools: Vec<String>,
) -> Arc<Self> {
Arc::new(Self {
gate_approval_tools,
gate_auth_tools,
chained_approval_then_auth_tools,
calls: RwLock::new(Vec::new()),
approved: RwLock::new(std::collections::HashSet::new()),
authenticated: RwLock::new(std::collections::HashSet::new()),
})
}
#[allow(dead_code)]
async fn recorded_calls(&self) -> Vec<(String, serde_json::Value)> {
self.calls.read().await.clone()
}
#[allow(dead_code)]
async fn mark_approved(&self, tool_name: &str) {
self.approved.write().await.insert(tool_name.to_string());
}
#[allow(dead_code)]
async fn mark_authenticated(&self, tool_name: &str) {
self.authenticated
.write()
.await
.insert(tool_name.to_string());
}
}
#[async_trait::async_trait]
impl EffectExecutor for GateMockEffects {
async fn execute_action(
&self,
action_name: &str,
parameters: serde_json::Value,
_lease: &CapabilityLease,
_context: &ironclaw_engine::ThreadExecutionContext,
) -> Result<ActionResult, EngineError> {
self.calls
.write()
.await
.push((action_name.to_string(), parameters.clone()));
let already_approved = self.approved.read().await.contains(action_name);
let already_authenticated = self.authenticated.read().await.contains(action_name);
if self
.chained_approval_then_auth_tools
.contains(&action_name.to_string())
{
if !already_approved {
return Err(EngineError::GatePaused {
gate_name: "approval".into(),
action_name: action_name.to_string(),
call_id: "call_gate_1".into(),
parameters: Box::new(parameters),
resume_kind: Box::new(ResumeKind::Approval { allow_always: true }),
resume_output: None,
});
}
if !already_authenticated {
return Err(EngineError::GatePaused {
gate_name: "authentication".into(),
action_name: action_name.to_string(),
call_id: "call_gate_2".into(),
parameters: Box::new(parameters),
resume_kind: Box::new(ResumeKind::Authentication {
credential_name: "notion".into(),
instructions: "Authenticate your Notion workspace".into(),
auth_url: None,
}),
resume_output: None,
});
}
}
// Gate: approval required
if self.gate_approval_tools.contains(&action_name.to_string()) && !already_approved {
return Err(EngineError::GatePaused {
gate_name: "approval".into(),
action_name: action_name.to_string(),
call_id: "call_gate_1".into(),
parameters: Box::new(parameters),
resume_kind: Box::new(ResumeKind::Approval { allow_always: true }),
resume_output: None,
});
}
// Gate: authentication required
if self.gate_auth_tools.contains(&action_name.to_string()) && !already_authenticated {
return Err(EngineError::GatePaused {
gate_name: "authentication".into(),
action_name: action_name.to_string(),
call_id: "call_gate_2".into(),
parameters: Box::new(parameters),
resume_kind: Box::new(ResumeKind::Authentication {
credential_name: "test_api_key".into(),
instructions: "Provide your API key".into(),
auth_url: None,
}),
resume_output: None,
});
}
Ok(ActionResult {
call_id: String::new(),
action_name: action_name.to_string(),
output: serde_json::json!({"status": "ok", "result": "success"}),
is_error: false,
duration: Duration::from_millis(1),
})
}
async fn available_actions(
&self,
_leases: &[CapabilityLease],
) -> Result<Vec<ActionDef>, EngineError> {
// requires_approval: false — the gate check is done by the mock's
// execute_action() returning GatePaused, not by the PolicyEngine.
Ok(vec![
ActionDef {
name: "http".into(),
description: "Make HTTP requests".into(),
parameters_schema: serde_json::json!({"type": "object"}),
effects: vec![EffectType::WriteExternal],
requires_approval: false,
},
ActionDef {
name: "echo".into(),
description: "Echo input".into(),
parameters_schema: serde_json::json!({"type": "object"}),
effects: vec![EffectType::ReadLocal],
requires_approval: false,
},
ActionDef {
name: "tool_install".into(),
description: "Install an extension".into(),
parameters_schema: serde_json::json!({"type": "object"}),
effects: vec![EffectType::WriteExternal],
requires_approval: false,
},
])
}
}
// ── In-Memory Store (same as engine_v2_skill_codeact) ────────
struct TestStore {
threads: RwLock<HashMap<ThreadId, Thread>>,
events: RwLock<Vec<ThreadEvent>>,
docs: RwLock<Vec<MemoryDoc>>,
missions: RwLock<Vec<Mission>>,
leases: RwLock<Vec<CapabilityLease>>,
steps: RwLock<Vec<Step>>,
}
impl TestStore {
fn new() -> Arc<Self> {
Arc::new(Self {
threads: RwLock::new(HashMap::new()),
events: RwLock::new(Vec::new()),
docs: RwLock::new(Vec::new()),
missions: RwLock::new(Vec::new()),
leases: RwLock::new(Vec::new()),
steps: RwLock::new(Vec::new()),
})
}
}
#[async_trait::async_trait]
impl Store for TestStore {
async fn save_thread(&self, thread: &Thread) -> Result<(), EngineError> {
self.threads.write().await.insert(thread.id, thread.clone());
Ok(())
}
async fn load_thread(&self, id: ThreadId) -> Result<Option<Thread>, EngineError> {
Ok(self.threads.read().await.get(&id).cloned())
}
async fn list_threads(
&self,
pid: ProjectId,
_user_id: &str,
) -> Result<Vec<Thread>, EngineError> {
Ok(self
.threads
.read()
.await
.values()
.filter(|t| t.project_id == pid)
.cloned()
.collect())
}
async fn update_thread_state(
&self,
id: ThreadId,
state: ThreadState,
) -> Result<(), EngineError> {
if let Some(t) = self.threads.write().await.get_mut(&id) {
t.state = state;
}
Ok(())
}
async fn save_step(&self, step: &Step) -> Result<(), EngineError> {
let mut steps = self.steps.write().await;
steps.retain(|s| s.id != step.id);
steps.push(step.clone());
Ok(())
}
async fn load_steps(&self, thread_id: ThreadId) -> Result<Vec<Step>, EngineError> {
Ok(self
.steps
.read()
.await
.iter()
.filter(|s| s.thread_id == thread_id)
.cloned()
.collect())
}
async fn append_events(&self, events: &[ThreadEvent]) -> Result<(), EngineError> {
self.events.write().await.extend(events.iter().cloned());
Ok(())
}
async fn load_events(&self, thread_id: ThreadId) -> Result<Vec<ThreadEvent>, EngineError> {
Ok(self
.events
.read()
.await
.iter()
.filter(|e| e.thread_id == thread_id)
.cloned()
.collect())
}
async fn save_project(&self, _project: &Project) -> Result<(), EngineError> {
Ok(())
}
async fn load_project(&self, _id: ProjectId) -> Result<Option<Project>, EngineError> {
Ok(None)
}
async fn save_memory_doc(&self, doc: &MemoryDoc) -> Result<(), EngineError> {
let mut docs = self.docs.write().await;
docs.retain(|d| d.id != doc.id);
docs.push(doc.clone());
Ok(())
}
async fn load_memory_doc(&self, id: DocId) -> Result<Option<MemoryDoc>, EngineError> {
Ok(self.docs.read().await.iter().find(|d| d.id == id).cloned())
}
async fn list_memory_docs(
&self,
_pid: ProjectId,
_user_id: &str,
) -> Result<Vec<MemoryDoc>, EngineError> {
Ok(self.docs.read().await.clone())
}
async fn save_lease(&self, lease: &CapabilityLease) -> Result<(), EngineError> {
let mut leases = self.leases.write().await;
leases.retain(|l| l.id != lease.id);
leases.push(lease.clone());
Ok(())
}
async fn load_active_leases(
&self,
thread_id: ThreadId,
) -> Result<Vec<CapabilityLease>, EngineError> {
Ok(self
.leases
.read()
.await
.iter()
.filter(|l| l.thread_id == thread_id && !l.revoked)
.cloned()
.collect())
}
async fn revoke_lease(&self, lease_id: LeaseId, _reason: &str) -> Result<(), EngineError> {
if let Some(l) = self
.leases
.write()
.await
.iter_mut()
.find(|l| l.id == lease_id)
{
l.revoked = true;
}
Ok(())
}
async fn save_mission(&self, mission: &Mission) -> Result<(), EngineError> {
let mut missions = self.missions.write().await;
missions.retain(|m| m.id != mission.id);
missions.push(mission.clone());
Ok(())
}
async fn load_mission(&self, id: MissionId) -> Result<Option<Mission>, EngineError> {
Ok(self
.missions
.read()
.await
.iter()
.find(|m| m.id == id)
.cloned())
}
async fn list_missions(
&self,
_pid: ProjectId,
_user_id: &str,
) -> Result<Vec<Mission>, EngineError> {
Ok(self.missions.read().await.clone())
}
async fn update_mission_status(
&self,
_id: MissionId,
_status: MissionStatus,
) -> Result<(), EngineError> {
Ok(())
}
}
// ── Helpers ──────────────────────────────────────────────────
fn make_caps(require_approval: bool) -> CapabilityRegistry {
let mut caps = CapabilityRegistry::new();
caps.register(Capability {
name: "tools".into(),
description: "test tools".into(),
actions: vec![
ActionDef {
name: "http".into(),
description: "HTTP requests".into(),
parameters_schema: serde_json::json!({"type": "object"}),
effects: vec![EffectType::WriteExternal],
requires_approval: require_approval,
},
ActionDef {
name: "echo".into(),
description: "Echo".into(),
parameters_schema: serde_json::json!({"type": "object"}),
effects: vec![EffectType::ReadLocal],
requires_approval: false,
},
ActionDef {
name: "tool_install".into(),
description: "Install a tool".into(),
parameters_schema: serde_json::json!({"type": "object"}),
effects: vec![EffectType::WriteExternal],
requires_approval: require_approval,
},
],
knowledge: vec![],
policies: vec![],
});
caps
}
fn make_caps_with_approval_tool() -> CapabilityRegistry {
let mut caps = CapabilityRegistry::new();
caps.register(Capability {
name: "tools".into(),
description: "test tools".into(),
actions: vec![ActionDef {
name: "approval_test".into(),
description: "Approval test tool".into(),
parameters_schema: serde_json::json!({"type": "object"}),
effects: vec![EffectType::WriteExternal],
requires_approval: false,
}],
knowledge: vec![],
policies: vec![],
});
caps
}
fn sample_pending_gate(
user_id: &str,
thread_id: ThreadId,
channel: &str,
resume_kind: ResumeKind,
) -> PendingGate {
PendingGate {
request_id: uuid::Uuid::new_v4(),
gate_name: "approval".into(),
user_id: user_id.into(),
thread_id,
scope_thread_id: None,
conversation_id: ironclaw_engine::ConversationId::new(),
source_channel: channel.into(),
action_name: "http".into(),
call_id: "call_1".into(),
parameters: serde_json::json!({"url": "https://example.com"}),
display_parameters: None,
description: "Tool 'http' requires approval".into(),
resume_kind,
created_at: Utc::now(),
expires_at: Utc::now() + chrono::Duration::minutes(30),
original_message: None,
resume_output: None,
approval_already_granted: false,
}
}
fn resumed_action_result_message(
call_id: &str,
action_name: &str,
output: &serde_json::Value,
) -> ThreadMessage {
let rendered = serde_json::to_string_pretty(output).unwrap_or_else(|_| output.to_string());
ThreadMessage::action_result(call_id, action_name, rendered)
}
// ── Tests: GatePaused ThreadOutcome ──────────────────────────
/// When effect executor returns GatePaused, the thread transitions to
/// Waiting and the outcome carries the gate info.
#[tokio::test]
async fn gate_paused_transitions_thread_to_waiting() {
let project_id = ProjectId::new();
let effects = GateMockEffects::new(vec!["http".into()], vec![]);
// LLM returns a structured tool call for http
let llm = ScriptedLlm::new(vec![LlmOutput {
response: LlmResponse::ActionCalls {
calls: vec![ironclaw_engine::ActionCall {
id: "call_1".into(),
action_name: "http".into(),
parameters: serde_json::json!({"url": "https://example.com"}),
}],
content: None,
},
usage: TokenUsage::default(),
}]);
let store = TestStore::new();
// Use requires_approval=false so PolicyEngine doesn't intercept before
// EffectExecutor — the mock returns GatePaused from execute_action().
let mgr = ThreadManager::new(
llm,
effects,
store.clone() as Arc<dyn Store>,
Arc::new(make_caps(false)),
Arc::new(LeaseManager::new()),
Arc::new(PolicyEngine::new()),
);
let tid = mgr
.spawn_thread(
"make an http post",
ThreadType::Foreground,
project_id,
ThreadConfig::default(),
None,
"test-user",
)
.await
.expect("spawn_thread");
let outcome = mgr.join_thread(tid).await.expect("join_thread");
// Thread should have paused with GatePaused outcome
match &outcome {
ThreadOutcome::GatePaused {
gate_name,
action_name,
resume_kind,
..
} => {
assert_eq!(gate_name, "approval");
assert_eq!(action_name, "http");
assert!(matches!(resume_kind, ResumeKind::Approval { .. }));
}
other => panic!("Expected GatePaused, got: {other:?}"),
}
// Thread state should be Waiting (safety net in loop_engine.rs)
let thread = store.load_thread(tid).await.unwrap().unwrap();
assert_eq!(
thread.state,
ThreadState::Waiting,
"Thread should be in Waiting state after GatePaused"
);
}
/// GatePaused with Authentication resume kind carries credential info.
#[tokio::test]
async fn gate_paused_authentication_carries_credential_name() {
let project_id = ProjectId::new();
let effects = GateMockEffects::new(vec![], vec!["http".into()]);
let llm = ScriptedLlm::new(vec![LlmOutput {
response: LlmResponse::ActionCalls {
calls: vec![ironclaw_engine::ActionCall {
id: "call_1".into(),
action_name: "http".into(),
parameters: serde_json::json!({"url": "https://api.example.com"}),
}],
content: None,
},
usage: TokenUsage::default(),
}]);
let store = TestStore::new();
let mgr = ThreadManager::new(
llm,
effects,
store.clone() as Arc<dyn Store>,
Arc::new(make_caps(false)), // false so PolicyEngine doesn't intercept
Arc::new(LeaseManager::new()),
Arc::new(PolicyEngine::new()),
);
let tid = mgr
.spawn_thread(
"fetch data from API",
ThreadType::Foreground,
project_id,
ThreadConfig::default(),
None,
"test-user",
)
.await
.expect("spawn_thread");
let outcome = mgr.join_thread(tid).await.expect("join_thread");
match &outcome {
ThreadOutcome::GatePaused {
gate_name,
resume_kind,
..
} => {
assert_eq!(gate_name, "authentication");
match resume_kind {
ResumeKind::Authentication {
credential_name, ..
} => {
assert_eq!(credential_name, "test_api_key");
}
other => panic!("Expected Authentication, got: {other:?}"),
}
}
other => panic!("Expected GatePaused, got: {other:?}"),
}
}
/// A paused thread remains resumable and completes after approval.
#[tokio::test]
async fn gate_paused_thread_resumes_to_completion() {
let project_id = ProjectId::new();
let effects = GateMockEffects::new(vec!["http".into()], vec![]);
let llm = ScriptedLlm::new(vec![
LlmOutput {
response: LlmResponse::ActionCalls {
calls: vec![ironclaw_engine::ActionCall {
id: "call_1".into(),
action_name: "http".into(),
parameters: serde_json::json!({"url": "https://example.com"}),
}],
content: None,
},
usage: TokenUsage::default(),
},
LlmOutput {
response: LlmResponse::Text("done".into()),
usage: TokenUsage::default(),
},
]);
let store = TestStore::new();
let mgr = ThreadManager::new(
llm,
effects.clone(),
store.clone() as Arc<dyn Store>,
Arc::new(make_caps(false)),
Arc::new(LeaseManager::new()),
Arc::new(PolicyEngine::new()),
);
let tid = mgr
.spawn_thread(
"make an http post",
ThreadType::Foreground,
project_id,
ThreadConfig::default(),
None,
"test-user",
)
.await
.expect("spawn_thread");
let first = mgr.join_thread(tid).await.expect("first join");
assert!(matches!(first, ThreadOutcome::GatePaused { .. }));
assert_eq!(
store.load_thread(tid).await.unwrap().unwrap().state,
ThreadState::Waiting
);
effects.mark_approved("http").await;
mgr.resume_thread(
tid,
"test-user",
Some(ThreadMessage::user("approved")),
Some(("call_gate_1".into(), true)),
None,
)
.await
.expect("resume_thread");
let resumed = mgr.join_thread(tid).await.expect("second join");
if !matches!(resumed, ThreadOutcome::Completed { .. }) {
panic!("expected Completed after approved retry, got {:?}", resumed);
}
let saved = store.load_thread(tid).await.unwrap().unwrap();
assert_eq!(saved.state, ThreadState::Done);
assert!(
saved.events.iter().any(|event| matches!(
event.kind,
ironclaw_engine::types::event::EventKind::ApprovalReceived { .. }
)),
"resume should record ApprovalReceived"
);
}
#[tokio::test]
async fn approval_resolution_executes_pending_call_directly() {
let project_id = ProjectId::new();
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(ApprovalTool)).await;
let effects = Arc::new(EffectBridgeAdapter::new(
tools,
Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 10_000,
injection_check_enabled: false,
})),
Arc::new(HookRegistry::default()),
));
let llm = ScriptedLlm::new(vec![
LlmOutput {
response: LlmResponse::ActionCalls {
calls: vec![ironclaw_engine::ActionCall {
id: "call_approval_1".into(),
action_name: "approval_test".into(),
parameters: serde_json::json!({"value": "hello"}),
}],
content: None,
},
usage: TokenUsage::default(),
},
LlmOutput {
response: LlmResponse::Text("done".into()),
usage: TokenUsage::default(),
},
]);
let store = TestStore::new();
let mgr = ThreadManager::new(
llm,
effects.clone(),
store.clone() as Arc<dyn Store>,
Arc::new(make_caps_with_approval_tool()),
Arc::new(LeaseManager::new()),
Arc::new(PolicyEngine::new()),
);
let tid = mgr
.spawn_thread(
"run the approval tool",
ThreadType::Foreground,
project_id,
ThreadConfig::default(),
None,
"test-user",
)
.await
.expect("spawn_thread");
let first = mgr.join_thread(tid).await.expect("first join");
match first {
ThreadOutcome::GatePaused {
gate_name,
action_name,
call_id,
parameters,
resume_kind,
..
} => {
assert_eq!(gate_name, "approval");
assert_eq!(action_name, "approval_test");
assert_eq!(call_id, "call_approval_1");
assert_eq!(parameters["value"], "hello");
assert!(matches!(resume_kind, ResumeKind::Approval { .. }));
}
other => panic!("expected GatePaused approval, got {other:?}"),
}
assert_eq!(
store.load_thread(tid).await.unwrap().unwrap().state,
ThreadState::Waiting
);
let thread = store.load_thread(tid).await.unwrap().unwrap();
let lease = mgr
.leases
.find_lease_for_action(tid, "approval_test")
.await
.expect("lease for approval_test");
let exec_ctx = ironclaw_engine::ThreadExecutionContext {
thread_id: tid,
thread_type: thread.thread_type,
project_id: thread.project_id,
user_id: "test-user".into(),
step_id: ironclaw_engine::StepId::new(),
current_call_id: Some("call_approval_1".into()),
source_channel: None,
user_timezone: None,
};
let tool_result = effects
.execute_resolved_pending_action(
"approval_test",
serde_json::json!({"value": "hello"}),
&lease,
&exec_ctx,
true,
)
.await
.expect("approved pending call should execute directly");
mgr.resume_thread(
tid,
"test-user",
Some(resumed_action_result_message(
"call_approval_1",
"approval_test",
&tool_result.output,
)),
Some(("call_approval_1".into(), true)),
Some("call_approval_1".into()),
)
.await
.expect("resume_thread");
let resumed = mgr.join_thread(tid).await.expect("second join");
assert!(
matches!(resumed, ThreadOutcome::Completed { .. }),
"expected Completed after approval retry, got {resumed:?}"
);
let saved = store.load_thread(tid).await.unwrap().unwrap();
assert_eq!(saved.state, ThreadState::Done);
let approval_requests = saved
.events
.iter()
.filter(|event| {
matches!(
event.kind,
ironclaw_engine::types::event::EventKind::ApprovalRequested { .. }
)
})
.count();
assert_eq!(
approval_requests, 1,
"resumed execution should not prompt for approval again"
);
assert!(
saved.events.iter().any(|event| matches!(
event.kind,
ironclaw_engine::types::event::EventKind::ApprovalReceived { .. }
)),
"resume should record ApprovalReceived"
);
}
#[tokio::test]
async fn auth_resolution_retries_same_pending_action_without_second_pause() {
let project_id = ProjectId::new();
let effects = GateMockEffects::new(vec![], vec!["http".into()]);
let llm = ScriptedLlm::new(vec![
LlmOutput {
response: LlmResponse::ActionCalls {
calls: vec![ironclaw_engine::ActionCall {
id: "call_auth_1".into(),
action_name: "http".into(),
parameters: serde_json::json!({"url": "https://example.com/private"}),
}],
content: None,
},
usage: TokenUsage::default(),
},
LlmOutput {
response: LlmResponse::ActionCalls {
calls: vec![ironclaw_engine::ActionCall {
id: "call_auth_2".into(),
action_name: "http".into(),
parameters: serde_json::json!({"url": "https://example.com/private"}),
}],
content: None,
},
usage: TokenUsage::default(),
},
LlmOutput {
response: LlmResponse::Text("done".into()),
usage: TokenUsage::default(),
},
]);
let store = TestStore::new();
let mgr = ThreadManager::new(
llm,
effects.clone(),
store.clone() as Arc<dyn Store>,
Arc::new(make_caps(false)),
Arc::new(LeaseManager::new()),
Arc::new(PolicyEngine::new()),
);
let tid = mgr
.spawn_thread(
"call the authenticated endpoint",
ThreadType::Foreground,
project_id,
ThreadConfig::default(),
None,
"test-user",
)
.await
.expect("spawn_thread");
let first = mgr.join_thread(tid).await.expect("first join");
assert!(matches!(first, ThreadOutcome::GatePaused { .. }));
assert_eq!(
store.load_thread(tid).await.unwrap().unwrap().state,
ThreadState::Waiting
);
let thread = store.load_thread(tid).await.unwrap().unwrap();
let lease = mgr
.leases
.find_lease_for_action(tid, "http")
.await
.expect("lease for http");
let exec_ctx = ironclaw_engine::ThreadExecutionContext {
thread_id: tid,
thread_type: thread.thread_type,
project_id: thread.project_id,
user_id: "test-user".into(),
step_id: ironclaw_engine::StepId::new(),
current_call_id: Some("call_auth_1".into()),
source_channel: None,
user_timezone: None,
};
effects.mark_authenticated("http").await;
let result = effects
.execute_action(
"http",
serde_json::json!({"url": "https://example.com/private"}),
&lease,
&exec_ctx,
)
.await
.expect("authenticated pending action should execute directly");
mgr.resume_thread(
tid,
"test-user",
Some(resumed_action_result_message(
"call_auth_1",
"http",
&result.output,
)),
None,
Some("call_auth_1".into()),
)
.await
.expect("resume_thread");
let resumed = mgr.join_thread(tid).await.expect("second join");
assert!(
matches!(resumed, ThreadOutcome::Completed { .. }),
"expected Completed after auth retry, got {resumed:?}"
);
let saved = store.load_thread(tid).await.unwrap().unwrap();
let auth_pauses = saved
.events
.iter()
.filter(|event| {
matches!(
event.kind,
ironclaw_engine::types::event::EventKind::ApprovalRequested { .. }
)
})
.count();
assert_eq!(auth_pauses, 1, "resumed auth should not pause again");
}
#[tokio::test]
async fn approval_chains_directly_into_auth_for_install_flow() {
let project_id = ProjectId::new();
let effects = GateMockEffects::new_with_chain(vec![], vec![], vec!["tool_install".into()]);
let install_params = serde_json::json!({"kind": "mcp_server", "name": "notion"});
let llm = ScriptedLlm::new(vec![
LlmOutput {
response: LlmResponse::ActionCalls {
calls: vec![ironclaw_engine::ActionCall {
id: "call_install_1".into(),
action_name: "tool_install".into(),
parameters: install_params.clone(),
}],
content: None,
},
usage: TokenUsage::default(),
},
LlmOutput {
response: LlmResponse::Text("notion connected".into()),
usage: TokenUsage::default(),
},
]);
let store = TestStore::new();
let mgr = ThreadManager::new(
llm,
effects.clone(),
store.clone() as Arc<dyn Store>,
Arc::new(make_caps(false)),
Arc::new(LeaseManager::new()),
Arc::new(PolicyEngine::new()),
);
let tid = mgr
.spawn_thread(
"install notion",
ThreadType::Foreground,
project_id,
ThreadConfig::default(),
None,
"test-user",
)
.await
.expect("spawn_thread");
let first = mgr.join_thread(tid).await.expect("first join");
assert!(matches!(first, ThreadOutcome::GatePaused { .. }));
let thread = store.load_thread(tid).await.unwrap().unwrap();
let lease = mgr
.leases
.find_lease_for_action(tid, "tool_install")
.await
.expect("lease for tool_install");
let exec_ctx = ironclaw_engine::ThreadExecutionContext {
thread_id: tid,
thread_type: thread.thread_type,
project_id: thread.project_id,
user_id: "test-user".into(),
step_id: ironclaw_engine::StepId::new(),
current_call_id: Some("call_install_1".into()),
source_channel: None,
user_timezone: None,
};
effects.mark_approved("tool_install").await;
let auth_pause = effects
.execute_action("tool_install", install_params.clone(), &lease, &exec_ctx)
.await
.expect_err("approved install should chain directly into auth");
match auth_pause {
EngineError::GatePaused {
gate_name,
action_name,
resume_kind,
..
} => {
assert_eq!(gate_name, "authentication");
assert_eq!(action_name, "tool_install");
match *resume_kind {
ResumeKind::Authentication {
credential_name, ..
} => assert_eq!(credential_name, "notion"),
other => panic!("expected auth gate after install approval, got {other:?}"),
}
}
other => panic!("expected auth gate immediately after install approval, got {other:?}"),
}
effects.mark_authenticated("tool_install").await;
let install_result = effects
.execute_action("tool_install", install_params, &lease, &exec_ctx)
.await
.expect("authenticated install should complete directly");
mgr.resume_thread(
tid,
"test-user",
Some(resumed_action_result_message(
"call_install_1",
"tool_install",
&install_result.output,
)),
None,
Some("call_install_1".into()),
)
.await
.expect("resume after auth");
let final_outcome = mgr.join_thread(tid).await.expect("third join");
assert!(
matches!(final_outcome, ThreadOutcome::Completed { .. }),
"expected completion after auth, got {final_outcome:?}"
);
let calls = effects.recorded_calls().await;
let install_calls = calls
.iter()
.filter(|(name, _)| name == "tool_install")
.count();
assert_eq!(
install_calls, 3,
"install flow should retry once for approval and once for auth"
);
}
// ── Tests: PendingGateStore full lifecycle ────────────────────
/// Full lifecycle: insert gate → peek → take_verified → gate removed.
#[tokio::test]
async fn pending_gate_full_lifecycle() {
let store = PendingGateStore::in_memory();
let tid = ThreadId::new();
let gate = sample_pending_gate(
"user1",
tid,
"telegram",
ResumeKind::Approval { allow_always: true },
);
let key = gate.key();
let request_id = gate.request_id;
// Insert
store.insert(gate).await.unwrap();
// Peek (should find it)
let view = store.peek(&key).await;
assert!(view.is_some());
assert_eq!(view.unwrap().tool_name, "http");
// Take (should remove it)
let taken = store
.take_verified(&key, request_id, "telegram")
.await
.unwrap();
assert_eq!(taken.action_name, "http");
// Peek again (should be gone)
assert!(store.peek(&key).await.is_none());
}
/// Cross-channel: telegram gate cannot be resolved from slack.
#[tokio::test]
async fn cross_channel_approval_blocked() {
let store = PendingGateStore::in_memory();
let tid = ThreadId::new();
let gate = sample_pending_gate(
"user1",
tid,
"telegram",
ResumeKind::Approval { allow_always: true },
);
let key = gate.key();
let request_id = gate.request_id;
store.insert(gate).await.unwrap();
// Slack cannot resolve a telegram gate
let result = store.take_verified(&key, request_id, "slack").await;
assert!(matches!(
result,
Err(GateStoreError::ChannelMismatch { .. })
));
// Gate still exists (not consumed by failed attempt)
assert!(store.peek(&key).await.is_some());
// Telegram can resolve it
let taken = store.take_verified(&key, request_id, "telegram").await;
assert!(taken.is_ok());
}
/// Trusted channels (web, gateway) can resolve gates from any source.
#[tokio::test]
async fn trusted_channel_can_resolve_any_gate() {
let store = PendingGateStore::in_memory();
for &trusted in TRUSTED_GATE_CHANNELS {
let tid = ThreadId::new();
let gate = sample_pending_gate(
"user1",
tid,
"signal",
ResumeKind::Approval { allow_always: true },
);
let key = gate.key();
let request_id = gate.request_id;
store.insert(gate).await.unwrap();
let result = store.take_verified(&key, request_id, trusted).await;
assert!(
result.is_ok(),
"Trusted channel '{trusted}' should resolve gate from 'signal'"
);
}
}
/// Thread-scoped: thread A's gate is not visible to thread B.
#[tokio::test]
async fn gate_scoped_to_thread_no_leakage() {
let store = PendingGateStore::in_memory();
let tid_a = ThreadId::new();
let tid_b = ThreadId::new();
let gate_a = sample_pending_gate(
"user1",
tid_a,
"web",
ResumeKind::Approval { allow_always: true },
);
store.insert(gate_a).await.unwrap();
// Thread B should see nothing
let key_b = PendingGateKey {
user_id: "user1".into(),
thread_id: tid_b,
};
assert!(store.peek(&key_b).await.is_none());
// Thread A should see the gate
let key_a = PendingGateKey {
user_id: "user1".into(),
thread_id: tid_a,
};
assert!(store.peek(&key_a).await.is_some());
}
/// Expired gate: cannot be resolved.
#[tokio::test]
async fn expired_gate_cannot_be_resolved() {
let store = PendingGateStore::in_memory();
let tid = ThreadId::new();
let mut gate = sample_pending_gate(
"user1",
tid,
"web",
ResumeKind::Approval { allow_always: true },
);
gate.expires_at = Utc::now() - chrono::Duration::seconds(10); // already expired
let key = gate.key();
let request_id = gate.request_id;
store.insert(gate).await.unwrap();
// Take should fail with Expired
let result = store.take_verified(&key, request_id, "web").await;
assert!(matches!(result, Err(GateStoreError::Expired)));
// Peek should also return None for expired
assert!(store.peek(&key).await.is_none());
}
/// Wrong request_id: does NOT consume the gate (regression: 74cbe5c2).
#[tokio::test]
async fn wrong_request_id_does_not_consume_gate() {
let store = PendingGateStore::in_memory();
let tid = ThreadId::new();
let gate = sample_pending_gate(
"user1",
tid,
"web",
ResumeKind::Approval { allow_always: true },
);
let key = gate.key();
let correct_id = gate.request_id;
store.insert(gate).await.unwrap();
// Wrong ID fails
let wrong_id = uuid::Uuid::new_v4();
let result = store.take_verified(&key, wrong_id, "web").await;
assert!(matches!(result, Err(GateStoreError::RequestIdMismatch)));
// Correct ID still works (gate was NOT consumed)
let taken = store.take_verified(&key, correct_id, "web").await;
assert!(taken.is_ok());
}
/// Concurrent resolution: only one caller succeeds (regression: 52d935d7).
#[tokio::test]
async fn concurrent_resolution_exactly_one_succeeds() {
let store = Arc::new(PendingGateStore::in_memory());
let tid = ThreadId::new();
let gate = sample_pending_gate(
"user1",
tid,
"web",
ResumeKind::Approval { allow_always: true },
);
let key = gate.key();
let request_id = gate.request_id;
store.insert(gate).await.unwrap();
let s1 = Arc::clone(&store);
let s2 = Arc::clone(&store);
let k1 = key.clone();
let k2 = key;
let (r1, r2) = tokio::join!(
tokio::spawn(async move { s1.take_verified(&k1, request_id, "web").await }),
tokio::spawn(async move { s2.take_verified(&k2, request_id, "web").await }),
);
let results = [r1.unwrap(), r2.unwrap()];
let ok_count = results.iter().filter(|r| r.is_ok()).count();
let err_count = results.iter().filter(|r| r.is_err()).count();
assert_eq!(ok_count, 1, "Exactly one concurrent take must succeed");
assert_eq!(err_count, 1, "Exactly one concurrent take must fail");
}
// ── Tests: Persistence & Recovery ────────────────────────────
/// Gates survive persistence round-trip (restart recovery).
#[tokio::test]
async fn persistence_round_trip_survives_restart() {
use async_trait::async_trait;
use std::sync::Mutex as StdMutex;
struct FakePersistence {
gates: StdMutex<Vec<PendingGate>>,
}
#[async_trait]
impl ironclaw::gate::store::GatePersistence for FakePersistence {
async fn save(&self, gate: &PendingGate) -> Result<(), GateStoreError> {
self.gates.lock().unwrap().push(gate.clone());
Ok(())
}
async fn remove(&self, _key: &PendingGateKey) -> Result<(), GateStoreError> {
Ok(())
}
async fn load_all(&self) -> Result<Vec<PendingGate>, GateStoreError> {
Ok(self.gates.lock().unwrap().clone())
}
}
let tid = ThreadId::new();
let gate = sample_pending_gate(
"user1",
tid,
"telegram",
ResumeKind::Approval { allow_always: true },
);
let request_id = gate.request_id;
let persistence = Arc::new(FakePersistence {
gates: StdMutex::new(vec![]),
});
// Store 1: insert and persist
let store1 = PendingGateStore::new(Some(persistence.clone()));
store1.insert(gate).await.unwrap();
// Simulate restart: new store, restore from persistence
let store2 = PendingGateStore::new(Some(persistence));
let restored = store2.restore_from_persistence().await.unwrap();
assert_eq!(restored, 1);
// Gate resolvable from restored store
let key = PendingGateKey {
user_id: "user1".into(),
thread_id: tid,
};
let taken = store2.take_verified(&key, request_id, "telegram").await;
assert!(taken.is_ok(), "Gate should be resolvable after restart");
assert_eq!(taken.unwrap().action_name, "http");
}
// ── Tests: LeasePlanner thread-type scoping ──────────────────
/// Research threads cannot access Privileged or Administrative tools.
#[tokio::test]
async fn lease_planner_research_excludes_privileged() {
use ironclaw_engine::LeasePlanner;
let planner = LeasePlanner::new();
let caps = make_caps(true); // http has requires_approval=true → Privileged
let plans = planner.plan_for_thread(ThreadType::Research, &caps);
let all_actions: Vec<String> = plans
.iter()
.flat_map(|p| p.granted_actions.actions().to_vec())
.collect();
assert!(
all_actions.contains(&"echo".into()),
"Research should include ReadOnly tools"
);
assert!(
!all_actions.contains(&"http".into()),
"Research should NOT include Privileged tools"
);
}
/// Mission threads exclude Administrative tools (denylist).
#[tokio::test]
async fn lease_planner_mission_excludes_denylisted() {
use ironclaw_engine::LeasePlanner;
let mut caps = CapabilityRegistry::new();
caps.register(Capability {
name: "tools".into(),
description: "test".into(),
actions: vec![
ActionDef {
name: "echo".into(),
description: "Echo".into(),
parameters_schema: serde_json::json!({}),
effects: vec![EffectType::ReadLocal],
requires_approval: false,
},
ActionDef {
name: "routine_create".into(),
description: "Create routine".into(),
parameters_schema: serde_json::json!({}),
effects: vec![EffectType::WriteLocal],
requires_approval: false,
},
],
knowledge: vec![],
policies: vec![],
});
let planner = LeasePlanner::new();
let plans = planner.plan_for_thread(ThreadType::Mission, &caps);
let all_actions: Vec<String> = plans
.iter()
.flat_map(|p| p.granted_actions.actions().to_vec())
.collect();
assert!(all_actions.contains(&"echo".into()));
assert!(
!all_actions.contains(&"routine_create".into()),
"Mission should NOT include denylisted Administrative tools"
);
}
// ── Tests: Child lease inheritance ───────────────────────────
/// Child leases are the intersection of parent leases and requested actions.
#[tokio::test]
async fn child_lease_inherits_subset_of_parent() {
let mgr = LeaseManager::new();
let parent = ThreadId::new();
let child = ThreadId::new();
mgr.grant(
parent,
"tools",
GrantedActions::Specific(vec!["read".into(), "write".into(), "delete".into()]),
None,
None,
)
.await
.unwrap();
let mut requested = std::collections::HashSet::new();
requested.insert("write".into());
requested.insert("delete".into());
requested.insert("admin".into()); // not in parent
let child_leases = mgr
.derive_child_leases(parent, child, Some(&requested))
.await;
assert_eq!(child_leases.len(), 1);
let ga = &child_leases[0].granted_actions;
assert!(ga.covers("write"));
assert!(ga.covers("delete"));
assert!(
!ga.covers("admin"),
"Child cannot have actions parent doesn't have"
);
}
/// Expired parent leases produce no child leases (fail-closed).
#[tokio::test]
async fn expired_parent_yields_no_child_leases() {
let mgr = LeaseManager::new();
let parent = ThreadId::new();
let child = ThreadId::new();
// Grant a valid lease, then revoke it so it appears invalid to
// derive_child_leases. (Negative durations are now rejected by grant.)
let lease = mgr
.grant(
parent,
"tools",
GrantedActions::Specific(vec!["read".into()]),
None,
None,
)
.await
.unwrap();
mgr.revoke(lease.id, "test: simulating expired").await;
let child_leases = mgr.derive_child_leases(parent, child, None).await;
assert!(
child_leases.is_empty(),
"Revoked parent should yield no child leases"
);
}
/// Wildcard parent (granted_actions=[]) + requested subset should give
/// only the requested subset, NOT a wildcard child (regression: C3 review).
#[tokio::test]
async fn wildcard_parent_lease_gives_requested_subset_not_wildcard() {
let mgr = LeaseManager::new();
let parent = ThreadId::new();
let child = ThreadId::new();
// Wildcard parent: granted_actions=All means "all actions"
mgr.grant(parent, "tools", GrantedActions::All, None, None)
.await
.unwrap();
let mut requested = std::collections::HashSet::new();
requested.insert("read".into());
requested.insert("write".into());
let child_leases = mgr
.derive_child_leases(parent, child, Some(&requested))
.await;
assert_eq!(child_leases.len(), 1);
let ga = &child_leases[0].granted_actions;
// Child should get Specific(["read", "write"]), NOT All (wildcard)
let actions = ga.actions();
assert_eq!(
actions.len(),
2,
"Child of wildcard parent should get exactly the requested actions, not wildcard. Got: {actions:?}"
);
assert!(ga.covers("read"));
assert!(ga.covers("write"));
}
// ── Tests: LeaseGate integration ─────────────────────────────
/// LeaseGate denies actions without a valid lease.
#[tokio::test]
async fn lease_gate_denies_without_lease() {
use ironclaw_engine::gate::lease::LeaseGate;
use ironclaw_engine::gate::{ExecutionGate, ExecutionMode, GateContext, GateDecision};
let mgr = Arc::new(LeaseManager::new());
let tid = ThreadId::new();
// No leases granted
let gate = LeaseGate::new(Arc::clone(&mgr));
let ad = ActionDef {
name: "shell".into(),
description: String::new(),
parameters_schema: serde_json::json!({}),
effects: vec![EffectType::WriteLocal],
requires_approval: true,
};
let auto = std::collections::HashSet::new();
let params = serde_json::json!({});
let ctx = GateContext {
user_id: "user1",
thread_id: tid,
source_channel: "web",
action_name: &ad.name,
call_id: "call_1",
parameters: &params,
action_def: &ad,
execution_mode: ExecutionMode::Autonomous,
auto_approved: &auto,
};
assert!(
matches!(gate.evaluate(&ctx).await, GateDecision::Deny { .. }),
"LeaseGate should deny actions without a lease"
);
}
/// LeaseGate allows actions covered by a valid lease.
#[tokio::test]
async fn lease_gate_allows_with_valid_lease() {
use ironclaw_engine::gate::lease::LeaseGate;
use ironclaw_engine::gate::{ExecutionGate, ExecutionMode, GateContext, GateDecision};
let mgr = Arc::new(LeaseManager::new());
let tid = ThreadId::new();
mgr.grant(
tid,
"tools",
GrantedActions::Specific(vec!["shell".into()]),
None,
None,
)
.await
.unwrap();
let gate = LeaseGate::new(Arc::clone(&mgr));
let ad = ActionDef {
name: "shell".into(),
description: String::new(),
parameters_schema: serde_json::json!({}),
effects: vec![EffectType::WriteLocal],
requires_approval: true,
};
let auto = std::collections::HashSet::new();
let params = serde_json::json!({});
let ctx = GateContext {
user_id: "user1",
thread_id: tid,
source_channel: "web",
action_name: &ad.name,
call_id: "call_1",
parameters: &params,
action_def: &ad,
execution_mode: ExecutionMode::Autonomous,
auto_approved: &auto,
};
assert!(
matches!(gate.evaluate(&ctx).await, GateDecision::Allow),
"LeaseGate should allow actions covered by a valid lease"
);
}
// ── Tests: GatePipeline composition ──────────────────────────
/// Pipeline evaluates gates in priority order; first Deny wins.
#[tokio::test]
async fn pipeline_first_deny_wins() {
use ironclaw_engine::gate::pipeline::GatePipeline;
use ironclaw_engine::gate::{ExecutionGate, ExecutionMode, GateContext, GateDecision};
struct AlwaysAllow;
#[async_trait::async_trait]
impl ExecutionGate for AlwaysAllow {
fn name(&self) -> &str {
"allow"
}
fn priority(&self) -> u32 {
10
}
async fn evaluate(&self, _: &GateContext<'_>) -> GateDecision {
GateDecision::Allow
}
}
struct AlwaysDeny;
#[async_trait::async_trait]
impl ExecutionGate for AlwaysDeny {
fn name(&self) -> &str {
"deny"
}
fn priority(&self) -> u32 {
20
}
async fn evaluate(&self, _: &GateContext<'_>) -> GateDecision {
GateDecision::Deny {
reason: "blocked".into(),
}
}
}
let pipeline = GatePipeline::new(vec![
Arc::new(AlwaysAllow) as Arc<dyn ExecutionGate>,
Arc::new(AlwaysDeny),
]);
let ad = ActionDef {
name: "test".into(),
description: String::new(),
parameters_schema: serde_json::json!({}),
effects: vec![],
requires_approval: false,
};
let auto = std::collections::HashSet::new();
let params = serde_json::json!({});
let ctx = GateContext {
user_id: "user1",
thread_id: ThreadId::new(),
source_channel: "web",
action_name: &ad.name,
call_id: "call_1",
parameters: &params,
action_def: &ad,
execution_mode: ExecutionMode::Interactive,
auto_approved: &auto,
};
assert!(matches!(
pipeline.evaluate(&ctx).await,
GateDecision::Deny { .. }
));
}
// ── Tests: InteractiveAutoApprove mode ───────────────────────
/// Auto-approve mode: GatePaused(Approval) is NOT returned for
/// UnlessAutoApproved tools — they execute directly.
#[tokio::test]
async fn auto_approve_mode_skips_approval_for_standard_tools() {
let project_id = ProjectId::new();
// This mock returns GatePaused only when NOT already approved.
// In auto-approve mode, the engine should never reach this gate
// because the ApprovalGate allows UnlessAutoApproved through.
// But our mock sits at the EffectExecutor level, so we test that
// the tool executes successfully (no GatePaused outcome).
let effects = GateMockEffects::new(vec![], vec![]); // No gates — tool succeeds
let llm = ScriptedLlm::new(vec![LlmOutput {
response: LlmResponse::ActionCalls {
calls: vec![ironclaw_engine::ActionCall {
id: "call_1".into(),
action_name: "echo".into(),
parameters: serde_json::json!({"text": "hello"}),
}],
content: None,
},
usage: TokenUsage::default(),
}]);
let store = TestStore::new();
let mgr = ThreadManager::new(
llm,
effects,
store.clone() as Arc<dyn Store>,
Arc::new(make_caps(false)),
Arc::new(LeaseManager::new()),
Arc::new(PolicyEngine::new()),
);
let tid = mgr
.spawn_thread(
"echo hello",
ThreadType::Foreground,
project_id,
ThreadConfig::default(),
None,
"test-user",
)
.await
.expect("spawn_thread");
let outcome = mgr.join_thread(tid).await.expect("join_thread");
// Tool should have executed and completed (no approval pause)
assert!(
matches!(outcome, ThreadOutcome::Completed { .. }),
"Expected Completed in auto-approve mode, got: {outcome:?}"
);
}
/// Auto-approve mode: Always-gated tools still pause for explicit approval.
#[tokio::test]
async fn auto_approve_mode_still_pauses_always_tools() {
use ironclaw_engine::gate::{ExecutionMode, GateContext};
// Test the ApprovalGate directly since we need the mode check
// without a full ThreadManager setup.
let ad = ActionDef {
name: "dangerous_delete".into(),
description: String::new(),
parameters_schema: serde_json::json!({}),
effects: vec![EffectType::WriteExternal],
requires_approval: true, // This maps to Always in the real system
};
let auto = std::collections::HashSet::new();
let params = serde_json::json!({});
let ctx = GateContext {
user_id: "user1",
thread_id: ThreadId::new(),
source_channel: "web",
action_name: &ad.name,
call_id: "call_1",
parameters: &params,
action_def: &ad,
execution_mode: ExecutionMode::InteractiveAutoApprove,
auto_approved: &auto,
};
// In auto-approve mode, the RelayChannelGate still allows
// (it only checks channel suffix, not mode).
// But the PolicyEngine would catch requires_approval=true.
// This test validates the ExecutionMode semantics at the gate level.
// Verify the mode is correctly propagated
assert_eq!(ctx.execution_mode, ExecutionMode::InteractiveAutoApprove);
}