mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
* 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)
495 lines
17 KiB
Rust
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();
|
|
}
|
|
}
|