Files
optimclaw/tests/e2e_metrics_test.rs
369741fc60 Add generic host-verified /webhook/tools/{tool} ingress (#757)
* Add generic host-verified webhook ingress for tools

* Stabilize trace E2E test rig and approval behavior

* Fix webhook security issues from review feedback

- Reject tools without webhook_capability() (was unauthenticated RCE)
- Remove secret-in-query-string fallback (leak via logs/referrers)
- Require approval for event_emit tool (escalation via routine triggers)
- Simplify header_value() (HeaderMap already case-insensitive)
- Redact internal errors from webhook HTTP responses
- Remove unused hmac_timestamp_tolerance_secs field
- Add regression test for tool without webhook capability

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Harden webhook ingress: require auth mechanism, body limit layer, health check

- Reject webhook capabilities that declare no auth mechanism (empty
  WebhookCapability would previously allow unauthenticated access)
- Add DefaultBodyLimit layer to reject oversized payloads before buffering
- Health check (GET) now verifies tool has webhook_capability(), not just
  existence
- Add regression tests for all three fixes

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Fix auto_approve_tools inconsistency between dispatcher and thread_ops

dispatcher.rs skips all approval checks (including Always) when
auto_approve_tools is true, but thread_ops.rs still required approval
for Always tools. This caused deferred tool calls to unexpectedly halt
in test rigs and auto-approve configurations.

Match dispatcher behavior: short-circuit all approval when
auto_approve_tools is enabled.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-11 03:36:25 +00:00

292 lines
9.9 KiB
Rust

//! E2E test: validates that the metrics collection layer works.
//!
//! Exercises `TraceMetrics`, `ScenarioResult`, `RunResult`, and `compare_runs`
//! through actual agent execution via the TestRig.
#[cfg(feature = "libsql")]
mod support;
#[cfg(feature = "libsql")]
mod tests {
use std::time::Duration;
use crate::support::assertions::assert_all_tools_succeeded;
use crate::support::cleanup::CleanupGuard;
use crate::support::metrics::{RunResult, ScenarioResult, compare_runs};
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace;
const TEST_DIR: &str = "/tmp/ironclaw_metrics_test";
fn setup_test_dir() {
let _ = std::fs::remove_dir_all(TEST_DIR);
std::fs::create_dir_all(TEST_DIR).expect("failed to create test directory");
}
/// Verify that metrics are collected from a simple text-only trace.
#[tokio::test]
async fn test_metrics_collected_from_text_trace() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/simple_text.json"
))
.expect("failed to load simple_text.json");
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("hello").await;
let _responses = rig.wait_for_responses(1, Duration::from_secs(10)).await;
// Collect metrics.
let metrics = rig.collect_metrics().await;
// Should have made at least 1 LLM call.
assert!(
metrics.llm_calls >= 1,
"Expected >= 1 LLM call, got {}",
metrics.llm_calls
);
// Token counts should match the fixture (50 input, 10 output).
assert!(
metrics.input_tokens >= 50,
"Expected >= 50 input tokens, got {}",
metrics.input_tokens
);
assert!(
metrics.output_tokens >= 10,
"Expected >= 10 output tokens, got {}",
metrics.output_tokens
);
// Wall time should be > 0 (we waited for a response).
assert!(
metrics.wall_time_ms > 0,
"Expected wall_time_ms > 0, got {}",
metrics.wall_time_ms
);
// No tools in this trace.
assert!(
metrics.tool_calls.is_empty(),
"Expected no tool calls, got {:?}",
metrics.tool_calls
);
// Should have at least 1 turn.
assert!(
metrics.turns >= 1,
"Expected >= 1 turn, got {}",
metrics.turns
);
rig.shutdown();
}
/// Verify that metrics capture tool calls from a file write/read flow.
#[tokio::test]
async fn test_metrics_collected_from_tool_trace() {
setup_test_dir();
let _cleanup = CleanupGuard::new().dir(TEST_DIR);
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/file_write_read.json"
))
.expect("failed to load file_write_read.json");
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Please write a greeting to a file and read it back.")
.await;
let _responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
// Assert all tools completed successfully.
let completed = rig.tool_calls_completed();
assert_all_tools_succeeded(&completed);
let metrics = rig.collect_metrics().await;
// Should have made 3 LLM calls (write_file, read_file, final text).
assert!(
metrics.llm_calls >= 3,
"Expected >= 3 LLM calls, got {}",
metrics.llm_calls
);
// Token counts should be non-trivial.
assert!(metrics.input_tokens > 0, "Expected input_tokens > 0");
assert!(metrics.output_tokens > 0, "Expected output_tokens > 0");
// Should have captured tool invocations.
assert!(
metrics.total_tool_calls() >= 2,
"Expected >= 2 tool calls, got {}",
metrics.total_tool_calls()
);
// Both tools should have succeeded.
assert_eq!(
metrics.failed_tool_calls(),
0,
"Expected 0 failed tool calls"
);
// Verify specific tool names.
let tool_names: Vec<&str> = metrics.tool_calls.iter().map(|t| t.name.as_str()).collect();
assert!(
tool_names.contains(&"write_file"),
"Expected write_file in tool calls, got {:?}",
tool_names
);
assert!(
tool_names.contains(&"read_file"),
"Expected read_file in tool calls, got {:?}",
tool_names
);
rig.shutdown();
}
/// Verify that metrics serialize to JSON correctly (for CI consumption).
#[tokio::test]
async fn test_metrics_json_serialization() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/simple_text.json"
))
.expect("failed to load simple_text.json");
let rig = TestRigBuilder::new().with_trace(trace).build().await;
rig.send_message("hello").await;
let responses = rig.wait_for_responses(1, Duration::from_secs(10)).await;
let metrics = rig.collect_metrics().await;
// Build a ScenarioResult.
let scenario = ScenarioResult {
scenario_id: "test_metrics_json_serialization".to_string(),
passed: true,
trace: metrics,
response: responses
.first()
.map(|r| r.content.clone())
.unwrap_or_default(),
error: None,
turn_metrics: Vec::new(),
};
// Should serialize to valid JSON.
let json = serde_json::to_string_pretty(&scenario).expect("JSON serialization failed");
assert!(json.contains("\"scenario_id\""));
assert!(json.contains("\"wall_time_ms\""));
assert!(json.contains("\"llm_calls\""));
assert!(json.contains("\"input_tokens\""));
assert!(json.contains("\"output_tokens\""));
// Should deserialize back.
let deserialized: ScenarioResult =
serde_json::from_str(&json).expect("JSON deserialization failed");
assert_eq!(deserialized.scenario_id, scenario.scenario_id);
assert_eq!(deserialized.passed, scenario.passed);
rig.shutdown();
}
/// Verify RunResult aggregation and baseline comparison.
#[tokio::test]
async fn test_run_result_and_baseline_comparison() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/simple_text.json"
))
.expect("failed to load simple_text.json");
let rig = TestRigBuilder::new().with_trace(trace).build().await;
rig.send_message("hello").await;
let responses = rig.wait_for_responses(1, Duration::from_secs(10)).await;
let metrics = rig.collect_metrics().await;
// Create a "current" run result.
let current_scenario = ScenarioResult {
scenario_id: "smoke_test".to_string(),
passed: true,
trace: metrics,
response: responses
.first()
.map(|r| r.content.clone())
.unwrap_or_default(),
error: None,
turn_metrics: Vec::new(),
};
let current_run = RunResult::from_scenarios("current-run", vec![current_scenario]);
// Verify aggregation.
assert_eq!(current_run.pass_rate, 1.0);
assert_eq!(current_run.scenarios.len(), 1);
assert!(current_run.total_wall_time_ms > 0);
// Create a synthetic "baseline" with double the tokens (simulating regression).
let mut baseline_trace = current_run.scenarios[0].trace.clone();
baseline_trace.input_tokens /= 2; // Baseline had fewer tokens.
let baseline_scenario = ScenarioResult {
scenario_id: "smoke_test".to_string(),
passed: true,
trace: baseline_trace,
response: "baseline response".to_string(),
error: None,
turn_metrics: Vec::new(),
};
let baseline_run = RunResult::from_scenarios("baseline-run", vec![baseline_scenario]);
// Compare should detect token regression (current uses more tokens than baseline).
let deltas = compare_runs(&baseline_run, &current_run, 0.10);
let token_delta = deltas.iter().find(|d| d.metric == "total_tokens");
if let Some(d) = token_delta {
assert!(d.is_regression, "Expected token regression");
assert!(d.delta > 0.0, "Expected positive delta for regression");
}
rig.shutdown();
}
/// Verify that accessor methods on TestRig match InstrumentedLlm data.
#[tokio::test]
async fn test_rig_metric_accessors() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/simple_text.json"
))
.expect("failed to load simple_text.json");
let rig = TestRigBuilder::new().with_trace(trace).build().await;
// Before sending any message, metrics should be zero.
assert_eq!(rig.llm_call_count(), 0);
assert_eq!(rig.total_input_tokens(), 0);
assert_eq!(rig.total_output_tokens(), 0);
rig.send_message("hello").await;
let _responses = rig.wait_for_responses(1, Duration::from_secs(10)).await;
// After the agent processes, metrics should be populated.
assert!(rig.llm_call_count() >= 1);
assert!(rig.total_input_tokens() > 0);
assert!(rig.total_output_tokens() > 0);
assert!(rig.elapsed_ms() > 0);
rig.shutdown();
}
}