use std::backtrace::Backtrace;
use std::time::{Duration, UNIX_EPOCH};
use rmcp::model::{CallToolResult, ContentBlock, InputRequiredResult};
use serde_json::{Value, json};
use super::{from_call, render_backtrace};
use crate::config::UserIdentity;
use crate::event::{CallRecord, ClientIdentity, extra_value};
use crate::handles::{HandleResolution, SessionSource};
use crate::hooks::PendingHooks;
use crate::pipeline::settle::{HookResults, apply};
use crate::pipeline::{EventObject, TOOL_CALL_KEYS};
use crate::test_support::json::object;
use crate::test_support::rmcp::{assert_credentials_hidden, bare_context, credential_parts};
use crate::test_support::vectors;
fn record() -> CallRecord {
let response = CallToolResult::success(vec![ContentBlock::text("hi")]);
CallRecord {
started_at: UNIX_EPOCH + Duration::from_millis(1_700_000_000_250),
duration_ms: 750,
resolution: HandleResolution {
session_id: "ses_1".to_owned(),
session_source: SessionSource::Supplied,
hook_mode: false,
agent_id: None,
},
resource_name: "echo".to_owned(),
request: json!({"method": "tools/call", "params": {"name": "echo"}, "jsonrpc": "2.0", "id": 1}),
extra: json!({"requestId": 1}),
input_tokens: Some(5),
output_tokens: Some(2),
tags: object(json!({"agentcat_session_id_source": "supplied"})),
user_intent: Some(json!("why")),
response: Some(serde_json::to_value(response).expect("a result is JSON")),
is_error: false,
error: None,
server_name: Some("server".to_owned()),
server_version: Some("1.0.0".to_owned()),
client: Some(ClientIdentity {
name: Some("client".to_owned()),
version: Some("2.0.0".to_owned()),
}),
pending: PendingHooks::default(),
runtime: None,
backtrace: None,
}
}
fn expected_event_keys() -> Vec<(String, bool)> {
let cases = vectors::cases("event_order");
let case = cases
.iter()
.find(|case| vectors::name(case) == "tool_call")
.expect("event_order.json has the tool_call case");
case["event"]
.as_array()
.expect("event is a list")
.iter()
.map(|entry| {
let key = entry["key"].as_str().expect("a key").to_owned();
let marks = entry.as_object().expect("an entry is an object");
for mark in marks.keys() {
assert!(
["key", "absent"].contains(&mark.as_str()),
"event_order.json: the entry {key:?} has the unknown key {mark:?}"
);
}
(key, entry.get("absent") == Some(&json!(true)))
})
.collect()
}
fn keys(event: &EventObject) -> Vec<&str> {
event.keys().map(String::as_str).collect()
}
#[test]
fn tool_call_event_follows_the_fixture_key_order() {
let fixture = expected_event_keys();
let all: Vec<&str> = fixture.iter().map(|(key, _)| key.as_str()).collect();
assert_eq!(TOOL_CALL_KEYS.as_slice(), all.as_slice());
let defined: Vec<&str> = fixture
.iter()
.filter(|(_, absent)| !absent)
.map(|(key, _)| key.as_str())
.collect();
let mut job = from_call(record(), "proj_1");
let results = HookResults {
session_value: None,
identity: Some(
UserIdentity::new("user-1")
.user_name("User One")
.user_data(object(json!({"plan": "pro"}))),
),
tags: Some(object(json!({"env": "probe"}))),
properties: Some(object(json!({"device": "probe"}))),
};
apply(&mut job.event, results, false).expect("no session step");
assert_eq!(keys(&job.event), defined);
let mut failed = record();
failed.is_error = true;
failed.error = Some(object(json!({"message": "failed", "platform": "rust"})));
let job = from_call(failed, "proj_1");
let present: Vec<&str> = all
.iter()
.copied()
.filter(|key| job.event.contains_key(*key))
.collect();
assert_eq!(keys(&job.event), present);
assert_eq!(job.event["isError"], json!(true));
assert!(job.event.contains_key("error"));
}
#[tokio::test]
async fn event_takes_the_record_fields() {
let mut record = record();
record.runtime = tokio::runtime::Handle::try_current().ok();
record.backtrace = Some(Backtrace::force_capture());
record.resolution.hook_mode = true;
let job = from_call(record, "proj_1");
let expected = json!({
"id": "",
"sessionId": "ses_1",
"projectId": "proj_1",
"eventType": "mcp:tools/call",
"timestamp": "2023-11-14T22:13:20.250Z",
"duration": 750,
"inputTokens": 5,
"outputTokens": 2,
"sdkLanguage": "Rust",
"agentcatVersion": env!("CARGO_PKG_VERSION"),
"serverName": "server",
"serverVersion": "1.0.0",
"clientName": "client",
"clientVersion": "2.0.0",
"identifyActorData": {},
"resourceName": "echo",
"parameters": {
"request": {"method": "tools/call", "params": {"name": "echo"}, "jsonrpc": "2.0", "id": 1},
"extra": {"requestId": 1},
},
"response": {"content": [{"type": "text", "text": "hi"}], "isError": false},
"userIntent": "why",
"tags": {"agentcat_session_id_source": "supplied"},
});
assert_eq!(Value::Object(job.event.clone()), expected);
assert!(job.hook_mode);
assert!(job.string_redaction && job.event_redaction);
assert!(job.runtime.is_some());
assert!(job.backtrace.is_some());
assert!(!job.hooks.any());
}
#[test]
fn absent_fields_are_absent_keys() {
let mut record = record();
record.input_tokens = None;
record.output_tokens = None;
record.user_intent = None;
record.response = None;
record.server_name = None;
record.server_version = None;
record.client = Some(ClientIdentity {
name: None,
version: Some("2.0.0".to_owned()),
});
let job = from_call(record, "");
for key in [
"inputTokens",
"outputTokens",
"userIntent",
"response",
"serverName",
"serverVersion",
"clientName",
"isError",
"error",
"identifyActorGivenId",
"identifyActorName",
"properties",
] {
assert!(!job.event.contains_key(key), "{key} is present");
}
assert_eq!(job.event["clientVersion"], json!("2.0.0"));
assert_eq!(job.event["projectId"], json!(""));
}
#[test]
fn event_timestamp_is_iso_millis() {
let mut record = record();
record.started_at = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let job = from_call(record, "proj_1");
assert_eq!(job.event["timestamp"], json!("2023-11-14T22:13:20.000Z"));
}
#[test]
fn response_has_no_result_type() {
let job = from_call(record(), "proj_1");
let response = job.event["response"].as_object().expect("a response");
assert!(!response.contains_key("resultType"), "{response:?}");
assert_eq!(response["content"], json!([{"type": "text", "text": "hi"}]));
let mut record = record();
let input_required = InputRequiredResult::from_request_state("state-1");
record.response = Some(serde_json::to_value(input_required).expect("a result is JSON"));
let job = from_call(record, "proj_1");
assert_eq!(job.event["response"]["resultType"], json!("input_required"));
}
#[test]
fn sessionless_call_has_empty_session() {
let mut record = record();
record.resolution.session_id = String::new();
record.resolution.session_source = SessionSource::Invalid;
let job = from_call(record, "proj_1");
assert_eq!(job.event["sessionId"], json!(""));
}
#[test]
fn backtrace_renders_after_platform() {
let error = json!({
"message": "failed",
"type": "my::Error",
"platform": "rust",
"chained_errors": [{"message": "root cause"}],
});
let mut event = object(json!({"isError": true, "error": error.clone(), "tags": {}}));
let backtrace = Backtrace::force_capture();
render_backtrace(&mut event, &backtrace);
let rendered = event["error"].as_object().expect("an error");
assert_eq!(
keys(rendered),
["message", "type", "platform", "stack", "chained_errors"]
);
assert_eq!(rendered["stack"], json!(backtrace.to_string()));
let mut event = object(json!({"error": error.clone()}));
render_backtrace(&mut event, &Backtrace::disabled());
assert_eq!(event["error"], error);
}
#[tokio::test]
async fn event_job_debug_hides_credentials() {
let mut context = bare_context(None);
context.extensions.insert(credential_parts());
let mut record = record();
record.extra = extra_value(&context);
let job = from_call(record, "proj_1");
let text = format!("{job:?}");
assert_credentials_hidden(&text);
assert!(text.contains("echo"), "the other fields print: {text}");
}