Files
ironclaw/tests/e2e_approval_traces.rs
firat.sertgoz 8898d3ea4f test(harness): add Phase 2 replay and gateway coverage (#2896)
* test(replay): add approval round-trip fixtures (Phase 2 of #2828)

First fixture-driven Layer 1 (replay) coverage of the full v1 approval
cycle: pause -> user resolution -> resume. Companion to the existing
no_done_emitted_while_awaiting_approval test in e2e_response_order.rs,
which covers the pause but not the resume.

Three scenarios:
- approval_yes: user approves -> tool runs once -> final LLM response
- approval_no: user denies -> tool does NOT run -> agent surfaces a
  built-in rejection message (no follow-up LLM call, by design)
- approval_always: allow-always on first call -> second call runs
  without re-prompting, exactly one ApprovalNeeded total

Uses a test-only NeedsApprovalProbe tool with
ApprovalRequirement::UnlessAutoApproved registered via
TestRig::with_extra_tools, with auto_approve_tools(false) so the agent
actually pauses for resolution.

The deny-path discovery (no LLM follow-up on rejection) is documented
in the test so future readers don't reintroduce the trailing text step.

Updates tests/fixtures/llm_traces/README.md to list the new fixtures.
Bumps approvals coverage in the harness-testing matrix from ~ to (closer
to) full at Layer 1.

* test(replay): expand approval coverage with 4 missing scenarios

Adds the four approval scenarios that the original three-test set
omitted, completing the state-space matrix across ApprovalRequirement
variants, the master kill-switch config, and submission-routing edge
cases.

New tests (all in tests/e2e_approval_traces.rs):

- always_requirement_ignores_allow_always_persistence
  ApprovalRequirement::Always is the unbypassable hard floor — even an
  'allow-always' resolution must NOT skip the pause on subsequent calls
  of an Always-tool. Two pauses for two calls.

- slash_approve_routes_as_approval_response
  '/approve' is parsed as Submission::ApprovalResponse even though bare
  'yes' downgrades to UserInput when nothing is pending. Pins the
  divergent routing in submission.rs.

- bare_yes_with_no_pending_approval_is_user_input
  Bare 'yes' with no pending approval must downgrade to UserInput and
  reach the LLM as a normal user message. Asserts the routing layer in
  agent_loop.rs performs the downgrade (parser is stateless).

- config_auto_approve_bypasses_unless_auto_approved
  Agent-config auto_approve_tools=true is the master kill-switch — no
  ApprovalNeeded is ever emitted, even for UnlessAutoApproved tools.

Also adds AlwaysApprovalProbe (mirrors NeedsApprovalProbe but returns
ApprovalRequirement::Always) and three fixtures:

- approval_always_floor.json
- approval_slash.json
- approval_bare_yes_no_pending.json

README updated to list the new fixtures.

Phase 2 of #2828.

* test(replay): add auth-gate round-trip fixtures (Phase 2 of #2828)

Five replay fixtures covering the engine v2 auth-gate state space:
- auth_credential_provided: happy path (CredentialProvided -> resume)
- auth_cancelled: user rejects (Cancelled -> resume)
- auth_retry_invalid_then_valid: invalid credential, retry path
- auth_external_callback: ExternalCallback submission path
- auth_gate_request_id: AuthRequired populates request_id (v2 only)

Probe tool: MockActivateTool (name "tool_activate") with scriptable
output queue, installed via TestRegistry::replace_for_test to bypass
PROTECTED_TOOL_NAMES. Planted minimal SKILL.md provides the credential
spec needed by AuthManager's submit_auth_token path (otherwise the
auth flow short-circuits with "Extension not installed").

Rig additions:
- send_gate_auth_resolution(request_id, AuthGateResolution)
- send_external_callback(request_id)
- with_test_tool_override(tool) builder
- TestChannel::channel_name / user_id accessors

Serialization: all auth-gate tests share engine_v2_test_lock()
(per-file static Mutex) because engine v2 uses a process-global
OnceLock<RwLock<Option<EngineState>>>.

Fixtures omit tools_used / all_tools_succeeded because engine v2
suppresses ToolStarted/ToolCompleted events when a tool output
becomes a gate pause; verification uses the mock's internal
execution counter instead.

* test(router): cover auth fallback caller path (Phase 2 of #2828)

* test(harness): add gateway-ops trace replay runner (#643, Phase 2 of #2828)

Introduces Trace/TraceOperation/TraceExpectation types and TraceRunner
that replays an ordered sequence of tool invocations against a libSQL
test DB. The runner creates ActionRecords via the same save_action path
gateway handlers use and matches outcomes against declared expectations.

This is the inverse of the agentic TraceLlm harness: where TraceLlm
replays an LLM stream and asserts the agent re-produces tool calls,
TraceRunner replays caller-dispatched tool calls and asserts the
Tool -> ActionRecord -> save_action pipeline matches expectations.

Deliverables:
- tests/support/trace_runner.rs: Trace, TraceOperation, TraceExpectation
  (Success { assertions } / Failure { error_contains }), TraceResult
  (with job_id for DB cross-checks), TraceFailure, TraceRunner with
  replay(). Assertion DSL supports eq / contains_text / fields (dot-path).
- tests/e2e_gateway_trace_harness.rs: 7 integration tests covering echo
  roundtrip, idempotency, unknown-tool failure, mix assertions, forced
  mismatch detection, DB persistence via get_job_actions, and cross-run
  determinism.
- tests/fixtures/gateway_traces/: 4 JSON fixtures + README documenting
  the wire format and the deferred settings_* / extension_* roadmap
  (blocked on #640 and network-stub work respectively).

Pitfalls addressed:
- Parent agent_jobs row is created via save_job before the first
  save_action; job_actions.job_id has a FK to agent_jobs(id) ON DELETE
  CASCADE that would otherwise fail.
- Deterministic-field check in the determinism test excludes id /
  executed_at / duration (intentionally variable across replays).
- ToolError has no NotFound variant; missing-tool lookups are reported
  via ExecutionFailed("tool not registered: {name}") so Failure
  expectations can substring-match on "not registered".

* fix: address review findings (iteration 1)
2026-04-24 13:49:50 +03:00

495 lines
17 KiB
Rust

//! Replay coverage for the v1 approval round-trip.
//!
//! Phase 2 of #2828 — these are the first fixture-driven tests that
//! exercise the **full** approval cycle (pause → user resolution →
//! resume) rather than only the pause invariant. Companion file:
//! `tests/e2e_response_order.rs::no_done_emitted_while_awaiting_approval`,
//! which covers the pause but not the resume.
//!
//! Three scenarios:
//! - `approval_yes`: approve once → tool runs → final response
//! - `approval_no`: deny once → tool does not run → final response
//! - `approval_always`: allow-always on call 1 → call 2 runs without
//! re-prompting
#[cfg(feature = "libsql")]
mod support;
#[cfg(feature = "libsql")]
mod approval_trace_tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace;
use ironclaw::channels::StatusUpdate;
use ironclaw::context::JobContext;
use ironclaw::tools::{ApprovalRequirement, Tool, ToolError, ToolOutput};
const TIMEOUT: Duration = Duration::from_secs(15);
/// Test tool whose approval requirement is `UnlessAutoApproved`. With
/// `with_auto_approve_tools(false)` the agent must pause for user
/// approval; an `always`-approve response should persist for the
/// remainder of the session and skip the pause on subsequent calls.
struct NeedsApprovalProbe {
executions: Arc<AtomicUsize>,
}
impl NeedsApprovalProbe {
fn new() -> (Arc<Self>, Arc<AtomicUsize>) {
let executions = Arc::new(AtomicUsize::new(0));
let tool = Arc::new(Self {
executions: executions.clone(),
});
(tool, executions)
}
}
#[async_trait]
impl Tool for NeedsApprovalProbe {
fn name(&self) -> &str {
"needs_approval_probe"
}
fn description(&self) -> &str {
"Test tool that requires approval unless auto-approved"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": { "value": { "type": "string" } },
"required": ["value"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
self.executions.fetch_add(1, Ordering::SeqCst);
Ok(ToolOutput::success(
serde_json::json!({"ok": true, "echoed": params}),
Duration::from_millis(1),
))
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
}
/// Test tool whose approval requirement is `Always` — the unbypassable
/// hard floor. Even an `allow-always` response must NOT auto-approve
/// subsequent calls of an `Always` tool. See dispatcher.rs:521-525 for
/// the design comment this guards.
struct AlwaysApprovalProbe {
executions: Arc<AtomicUsize>,
}
impl AlwaysApprovalProbe {
fn new() -> (Arc<Self>, Arc<AtomicUsize>) {
let executions = Arc::new(AtomicUsize::new(0));
let tool = Arc::new(Self {
executions: executions.clone(),
});
(tool, executions)
}
}
#[async_trait]
impl Tool for AlwaysApprovalProbe {
fn name(&self) -> &str {
"always_approval_probe"
}
fn description(&self) -> &str {
"Test tool that always requires explicit approval (unbypassable)"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": { "value": { "type": "string" } },
"required": ["value"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
self.executions.fetch_add(1, Ordering::SeqCst);
Ok(ToolOutput::success(
serde_json::json!({"ok": true, "echoed": params}),
Duration::from_millis(1),
))
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::Always
}
}
/// Poll `captured_status_events` until an `ApprovalNeeded` is observed
/// or the deadline elapses. Returns true on success.
async fn wait_for_approval_needed(
rig: &crate::support::test_rig::TestRig,
timeout: Duration,
) -> bool {
let initial = rig
.captured_status_events()
.iter()
.filter(|s| matches!(s, StatusUpdate::ApprovalNeeded { .. }))
.count();
let deadline = tokio::time::Instant::now() + timeout;
loop {
let count = rig
.captured_status_events()
.iter()
.filter(|s| matches!(s, StatusUpdate::ApprovalNeeded { .. }))
.count();
if count > initial {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn fixture_path(name: &str) -> String {
format!(
"{}/tests/fixtures/llm_traces/coverage/{}",
env!("CARGO_MANIFEST_DIR"),
name
)
}
#[tokio::test]
async fn approval_yes_runs_tool_and_produces_final_response() {
let trace = LlmTrace::from_file(fixture_path("approval_yes.json"))
.expect("failed to load approval_yes.json");
let (tool, executions) = NeedsApprovalProbe::new();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_extra_tools(vec![tool as Arc<dyn Tool>])
.with_auto_approve_tools(false)
.build()
.await;
rig.clear().await;
rig.send_message("Run the gated probe").await;
assert!(
wait_for_approval_needed(&rig, TIMEOUT).await,
"expected ApprovalNeeded status before approval was sent"
);
assert_eq!(
executions.load(Ordering::SeqCst),
0,
"tool must not run before approval"
);
rig.send_message("yes").await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
assert_eq!(
executions.load(Ordering::SeqCst),
1,
"tool must run exactly once after approval"
);
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
/// On deny, the agent does **not** call the LLM again — it surfaces a
/// built-in rejection message directly to the user. The trace therefore
/// only needs the initial tool-call step; the rejection text comes from
/// the agent, not from a replayed LLM response.
#[tokio::test]
async fn approval_no_skips_tool_and_produces_final_response() {
let trace = LlmTrace::from_file(fixture_path("approval_no.json"))
.expect("failed to load approval_no.json");
let (tool, executions) = NeedsApprovalProbe::new();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_extra_tools(vec![tool as Arc<dyn Tool>])
.with_auto_approve_tools(false)
.build()
.await;
rig.clear().await;
rig.send_message("Run the gated probe").await;
assert!(
wait_for_approval_needed(&rig, TIMEOUT).await,
"expected ApprovalNeeded status before denial was sent"
);
rig.send_message("no").await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
assert_eq!(
executions.load(Ordering::SeqCst),
0,
"tool must not run when approval is denied"
);
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
#[tokio::test]
async fn approval_always_persists_for_subsequent_calls() {
let trace = LlmTrace::from_file(fixture_path("approval_always.json"))
.expect("failed to load approval_always.json");
let (tool, executions) = NeedsApprovalProbe::new();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_extra_tools(vec![tool as Arc<dyn Tool>])
.with_auto_approve_tools(false)
.build()
.await;
rig.clear().await;
rig.send_message("Run the gated probe twice").await;
assert!(
wait_for_approval_needed(&rig, TIMEOUT).await,
"expected ApprovalNeeded status before allow-always was sent"
);
let approval_needed_after_first = rig
.captured_status_events()
.iter()
.filter(|s| matches!(s, StatusUpdate::ApprovalNeeded { .. }))
.count();
assert_eq!(
approval_needed_after_first, 1,
"exactly one ApprovalNeeded should be pending before resolving"
);
rig.send_message("always").await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
assert_eq!(
executions.load(Ordering::SeqCst),
2,
"both tool calls must run after allow-always"
);
let total_approval_needed = rig
.captured_status_events()
.iter()
.filter(|s| matches!(s, StatusUpdate::ApprovalNeeded { .. }))
.count();
assert_eq!(
total_approval_needed, 1,
"second tool call must not re-prompt for approval after allow-always; got {} prompts",
total_approval_needed
);
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
/// `ApprovalRequirement::Always` is an unbypassable hard floor: an
/// `allow-always` response must NOT skip the pause on subsequent
/// calls of an `Always` tool. Two pauses for two calls.
#[tokio::test]
async fn always_requirement_ignores_allow_always_persistence() {
let trace = LlmTrace::from_file(fixture_path("approval_always_floor.json"))
.expect("failed to load approval_always_floor.json");
let (tool, executions) = AlwaysApprovalProbe::new();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_extra_tools(vec![tool as Arc<dyn Tool>])
.with_auto_approve_tools(false)
.build()
.await;
rig.clear().await;
rig.send_message("Run the always-gated probe twice").await;
// First pause + resolve with "always".
assert!(
wait_for_approval_needed(&rig, TIMEOUT).await,
"expected first ApprovalNeeded"
);
rig.send_message("always").await;
// Second pause must still happen because Always is unbypassable.
assert!(
wait_for_approval_needed(&rig, TIMEOUT).await,
"second ApprovalNeeded must fire even after allow-always: \
ApprovalRequirement::Always is the hard floor"
);
rig.send_message("yes").await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
let total_approval_needed = rig
.captured_status_events()
.iter()
.filter(|s| matches!(s, StatusUpdate::ApprovalNeeded { .. }))
.count();
assert_eq!(
total_approval_needed, 2,
"two Always-gated calls must produce exactly two ApprovalNeeded events"
);
assert_eq!(
executions.load(Ordering::SeqCst),
2,
"both gated calls must run after individual approvals"
);
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
/// Slash-prefixed `/approve` is parsed as `Submission::ApprovalResponse`
/// even though bare "yes" downgrades to UserInput when nothing is
/// pending. This guards the divergent routing in submission.rs.
#[tokio::test]
async fn slash_approve_routes_as_approval_response() {
let trace = LlmTrace::from_file(fixture_path("approval_slash.json"))
.expect("failed to load approval_slash.json");
let (tool, executions) = NeedsApprovalProbe::new();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_extra_tools(vec![tool as Arc<dyn Tool>])
.with_auto_approve_tools(false)
.build()
.await;
rig.clear().await;
rig.send_message("Run the gated probe").await;
assert!(
wait_for_approval_needed(&rig, TIMEOUT).await,
"expected ApprovalNeeded before /approve was sent"
);
rig.send_message("/approve").await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
assert_eq!(
executions.load(Ordering::SeqCst),
1,
"tool must run after /approve"
);
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
/// Bare "yes" with no pending approval must downgrade to UserInput and
/// reach the LLM as a normal user message. The submission parser is
/// stateless, so the routing layer in agent_loop.rs is responsible
/// for the downgrade — this test pins that contract.
#[tokio::test]
async fn bare_yes_with_no_pending_approval_is_user_input() {
let trace = LlmTrace::from_file(fixture_path("approval_bare_yes_no_pending.json"))
.expect("failed to load approval_bare_yes_no_pending.json");
let (tool, executions) = NeedsApprovalProbe::new();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_extra_tools(vec![tool as Arc<dyn Tool>])
.with_auto_approve_tools(false)
.build()
.await;
rig.clear().await;
rig.send_message("yes").await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
// The LLM must have been called with "yes" as a user message,
// proving the routing layer downgraded the bare keyword.
let captured = rig.captured_llm_requests();
assert!(
!captured.is_empty(),
"LLM must have been called — if zero calls, the routing layer \
incorrectly treated bare 'yes' as an approval response"
);
let last_user_yes = captured.iter().any(|msgs| {
msgs.iter().any(|m| {
matches!(m.role, ironclaw::llm::Role::User)
&& m.content.trim().eq_ignore_ascii_case("yes")
})
});
assert!(
last_user_yes,
"LLM conversation must include 'yes' as a user message"
);
// No approval gate should have been emitted — nothing was pending.
let approval_needed = rig
.captured_status_events()
.iter()
.filter(|s| matches!(s, StatusUpdate::ApprovalNeeded { .. }))
.count();
assert_eq!(
approval_needed, 0,
"no ApprovalNeeded should fire when nothing was pending"
);
assert_eq!(
executions.load(Ordering::SeqCst),
0,
"probe tool must not run — LLM did not call it"
);
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
/// Agent-config `auto_approve_tools=true` is the master kill-switch: it
/// short-circuits the entire approval gate, so no `ApprovalNeeded` is
/// ever emitted, even for `UnlessAutoApproved` tools. Reuses the
/// approval_yes fixture and verifies the tool runs without any
/// resolution being sent.
#[tokio::test]
async fn config_auto_approve_bypasses_unless_auto_approved() {
let trace = LlmTrace::from_file(fixture_path("approval_yes.json"))
.expect("failed to load approval_yes.json");
let (tool, executions) = NeedsApprovalProbe::new();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_extra_tools(vec![tool as Arc<dyn Tool>])
.with_auto_approve_tools(true)
.build()
.await;
rig.clear().await;
rig.send_message("Run the gated probe").await;
let responses = rig.wait_for_responses(1, TIMEOUT).await;
assert_eq!(
executions.load(Ordering::SeqCst),
1,
"tool must run with auto_approve_tools=true and no resolution sent"
);
let approval_needed = rig
.captured_status_events()
.iter()
.filter(|s| matches!(s, StatusUpdate::ApprovalNeeded { .. }))
.count();
assert_eq!(
approval_needed, 0,
"no ApprovalNeeded should fire with auto_approve_tools=true"
);
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
}