use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::event::{EventType, SessionEvent};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileAccess {
pub file_path: String,
pub agent_instance_id: String,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub additions: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deletions: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortAccess {
pub port: u16,
pub agent_instance_id: String,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkConnection {
pub destination: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
pub agent_instance_id: String,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessExecution {
pub process_name: String,
pub agent_instance_id: String,
pub started_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolInvocation {
pub tool_name: String,
pub agent_instance_id: String,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SideEffects {
pub files_read: Vec<FileAccess>,
pub files_written: Vec<FileAccess>,
pub ports_opened: Vec<PortAccess>,
pub network_connections: Vec<NetworkConnection>,
pub processes: Vec<ProcessExecution>,
pub tool_invocations: Vec<ToolInvocation>,
}
impl SideEffects {
pub fn from_events(events: &[SessionEvent]) -> Self {
let mut se = SideEffects::default();
let mut started_processes: BTreeMap<(String, String), usize> = BTreeMap::new();
for event in events {
match &event.event_type {
EventType::AgentReadFile { file_path, digest } => {
se.files_read.push(FileAccess {
file_path: file_path.clone(),
agent_instance_id: event.agent_instance_id.clone(),
timestamp: event.timestamp.clone(),
digest: digest.clone(),
operation: None,
additions: None,
deletions: None,
source: Some(source_from_meta(event, "hook")),
});
}
EventType::AgentWroteFile {
file_path,
digest,
operation,
additions,
deletions,
} => {
se.files_written.push(FileAccess {
file_path: file_path.clone(),
agent_instance_id: event.agent_instance_id.clone(),
timestamp: event.timestamp.clone(),
digest: digest.clone(),
operation: operation.clone(),
additions: *additions,
deletions: *deletions,
source: Some(source_from_meta(event, "hook")),
});
}
EventType::AgentOpenedPort { port, protocol } => {
se.ports_opened.push(PortAccess {
port: *port,
agent_instance_id: event.agent_instance_id.clone(),
timestamp: event.timestamp.clone(),
protocol: protocol.clone(),
});
}
EventType::AgentConnectedNetwork { destination, port } => {
se.network_connections.push(NetworkConnection {
destination: destination.clone(),
port: *port,
agent_instance_id: event.agent_instance_id.clone(),
timestamp: event.timestamp.clone(),
});
}
EventType::AgentStartedProcess {
process_name,
pid: _,
command,
} => {
let idx = se.processes.len();
se.processes.push(ProcessExecution {
process_name: process_name.clone(),
agent_instance_id: event.agent_instance_id.clone(),
started_at: event.timestamp.clone(),
exit_code: None,
duration_ms: None,
command: command.clone(),
source: Some(source_from_meta(event, "hook")),
});
started_processes
.insert((event.agent_instance_id.clone(), process_name.clone()), idx);
}
EventType::AgentCompletedProcess {
process_name,
exit_code,
duration_ms,
command,
} => {
let key = (event.agent_instance_id.clone(), process_name.clone());
if let Some(&idx) = started_processes.get(&key) {
if let Some(proc) = se.processes.get_mut(idx) {
proc.exit_code = *exit_code;
proc.duration_ms = *duration_ms;
if proc.command.is_none() {
proc.command = command.clone();
}
}
} else {
se.processes.push(ProcessExecution {
process_name: process_name.clone(),
agent_instance_id: event.agent_instance_id.clone(),
started_at: event.timestamp.clone(),
exit_code: *exit_code,
duration_ms: *duration_ms,
command: command.clone(),
source: Some(source_from_meta(event, "hook")),
});
}
}
EventType::AgentCalledTool {
tool_name,
duration_ms,
..
} => {
se.tool_invocations.push(ToolInvocation {
tool_name: tool_name.clone(),
agent_instance_id: event.agent_instance_id.clone(),
timestamp: event.timestamp.clone(),
duration_ms: *duration_ms,
});
promote_mcp_called_tool(event, tool_name, &mut se);
}
_ => {}
}
}
se
}
pub fn summary(&self) -> SideEffectSummary {
SideEffectSummary {
files_read: self.files_read.len() as u32,
files_written: self.files_written.len() as u32,
ports_opened: self.ports_opened.len() as u32,
network_connections: self.network_connections.len() as u32,
processes: self.processes.len() as u32,
tool_invocations: self.tool_invocations.len() as u32,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SideEffectSummary {
pub files_read: u32,
pub files_written: u32,
pub ports_opened: u32,
pub network_connections: u32,
pub processes: u32,
pub tool_invocations: u32,
}
fn meta_string(event: &SessionEvent, dotted_path: &str) -> Option<String> {
let mut cur = event.meta.as_ref()?;
for segment in dotted_path.split('.') {
cur = cur.get(segment)?;
}
cur.as_str().map(|s| s.to_string())
}
fn first_meta_string(event: &SessionEvent, paths: &[&str]) -> Option<String> {
for path in paths {
if let Some(v) = meta_string(event, path) {
if !v.is_empty() {
return Some(v);
}
}
}
None
}
fn source_from_meta(event: &SessionEvent, default: &str) -> String {
event
.meta
.as_ref()
.and_then(|m| m.get("source"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| default.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ToolCategory {
Read,
Write,
Process,
Unknown,
}
fn classify_tool(tool_name: &str) -> ToolCategory {
let t = tool_name.to_lowercase();
if t.contains("bash")
|| t.contains("shell")
|| t.contains("exec")
|| t.contains("run_command")
|| t.contains("ran_command")
{
return ToolCategory::Process;
}
if t.contains("write")
|| t.contains("edit")
|| t.contains("create_file")
|| t.contains("modify")
|| t.contains("patch")
|| t.contains("save_file")
|| t.contains("delete_file")
|| t.contains("remove_file")
|| t.contains("rename_file")
{
return ToolCategory::Write;
}
if t.contains("read")
|| t.contains("view_file")
|| t.contains("cat_file")
|| t.contains("open_file")
|| t.contains("get_file_contents")
{
return ToolCategory::Read;
}
ToolCategory::Unknown
}
fn promote_mcp_called_tool(event: &SessionEvent, tool_name: &str, se: &mut SideEffects) {
let category = classify_tool(tool_name);
let file_path = first_meta_string(
event,
&[
"tool_input.file_path",
"tool_input.path",
"tool_input.notebook_path",
"tool_input.target_file",
"file_path",
"path",
],
);
let command = first_meta_string(
event,
&["tool_input.command", "command", "tool_input.cmd", "cmd"],
);
match (category, file_path, command) {
(ToolCategory::Read, Some(p), _) => {
se.files_read.push(FileAccess {
file_path: p,
agent_instance_id: event.agent_instance_id.clone(),
timestamp: event.timestamp.clone(),
digest: None,
operation: None,
additions: None,
deletions: None,
source: Some("mcp".into()),
});
}
(ToolCategory::Write, Some(p), _) => {
se.files_written.push(FileAccess {
file_path: p,
agent_instance_id: event.agent_instance_id.clone(),
timestamp: event.timestamp.clone(),
digest: None,
operation: None,
additions: None,
deletions: None,
source: Some("mcp".into()),
});
}
(ToolCategory::Process, _, Some(cmd)) => {
let short = cmd.chars().take(120).collect::<String>();
se.processes.push(ProcessExecution {
process_name: short,
agent_instance_id: event.agent_instance_id.clone(),
started_at: event.timestamp.clone(),
exit_code: None,
duration_ms: None,
command: Some(cmd),
source: Some("mcp".into()),
});
}
(ToolCategory::Unknown, Some(p), _) => {
se.files_written.push(FileAccess {
file_path: p,
agent_instance_id: event.agent_instance_id.clone(),
timestamp: event.timestamp.clone(),
digest: None,
operation: None,
additions: None,
deletions: None,
source: Some("mcp".into()),
});
}
_ => {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::event::*;
fn evt(event_type: EventType) -> SessionEvent {
SessionEvent {
session_id: "ssn_001".into(),
event_id: generate_event_id(),
timestamp: "2026-04-05T08:00:00Z".into(),
sequence_no: 0,
trace_id: "t".into(),
span_id: "s".into(),
parent_span_id: None,
agent_id: "agent://test".into(),
agent_instance_id: "ai_1".into(),
agent_name: "test".into(),
agent_role: None,
host_id: "h".into(),
tool_runtime_id: None,
event_type,
artifact_ref: None,
meta: None,
}
}
#[test]
fn aggregates_file_and_tool_events() {
let events = vec![
evt(EventType::AgentReadFile {
file_path: "src/main.rs".into(),
digest: None,
}),
evt(EventType::AgentWroteFile {
file_path: "src/lib.rs".into(),
digest: Some("sha256:abc".into()),
operation: Some("modified".into()),
additions: Some(10),
deletions: Some(3),
}),
evt(EventType::AgentCalledTool {
tool_name: "read_file".into(),
tool_input_digest: None,
tool_output_digest: None,
duration_ms: Some(10),
}),
evt(EventType::AgentCalledTool {
tool_name: "write_file".into(),
tool_input_digest: None,
tool_output_digest: None,
duration_ms: None,
}),
];
let se = SideEffects::from_events(&events);
assert_eq!(se.files_read.len(), 1);
assert_eq!(se.files_written.len(), 1);
assert_eq!(se.tool_invocations.len(), 2);
let summary = se.summary();
assert_eq!(summary.tool_invocations, 2);
}
#[test]
fn matches_process_start_and_complete() {
let events = vec![
evt(EventType::AgentStartedProcess {
process_name: "npm test".into(),
pid: Some(1234),
command: Some("npm test --runInBand".into()),
}),
evt(EventType::AgentCompletedProcess {
process_name: "npm test".into(),
exit_code: Some(0),
duration_ms: Some(5000),
command: None,
}),
];
let se = SideEffects::from_events(&events);
assert_eq!(se.processes.len(), 1);
assert_eq!(se.processes[0].exit_code, Some(0));
assert_eq!(se.processes[0].duration_ms, Some(5000));
}
fn called_tool_with_meta(tool_name: &str, meta: serde_json::Value) -> SessionEvent {
let mut e = evt(EventType::AgentCalledTool {
tool_name: tool_name.into(),
tool_input_digest: None,
tool_output_digest: None,
duration_ms: None,
});
e.meta = Some(meta);
e
}
#[test]
fn hook_file_events_carry_source_hook() {
let events = vec![
evt(EventType::AgentReadFile {
file_path: "src/a.rs".into(),
digest: None,
}),
evt(EventType::AgentWroteFile {
file_path: "src/b.rs".into(),
digest: None,
operation: None,
additions: None,
deletions: None,
}),
];
let se = SideEffects::from_events(&events);
assert_eq!(se.files_read[0].source.as_deref(), Some("hook"));
assert_eq!(se.files_written[0].source.as_deref(), Some("hook"));
}
#[test]
fn mcp_called_tool_with_file_path_promotes_to_files_written() {
let events = vec![called_tool_with_meta(
"Edit",
serde_json::json!({
"source": "mcp-bridge",
"tool_input": { "file_path": "src/api/receipt.ts" },
}),
)];
let se = SideEffects::from_events(&events);
assert_eq!(
se.files_written.len(),
1,
"Edit with file_path must promote to files_written"
);
assert_eq!(se.files_written[0].file_path, "src/api/receipt.ts");
assert_eq!(se.files_written[0].source.as_deref(), Some("mcp"));
assert_eq!(se.tool_invocations.len(), 1);
}
#[test]
fn mcp_read_tool_promotes_to_files_read() {
let events = vec![called_tool_with_meta(
"Read",
serde_json::json!({ "tool_input": { "file_path": "package.json" } }),
)];
let se = SideEffects::from_events(&events);
assert_eq!(se.files_read.len(), 1);
assert_eq!(se.files_read[0].file_path, "package.json");
assert_eq!(se.files_read[0].source.as_deref(), Some("mcp"));
}
#[test]
fn mcp_bash_tool_promotes_to_processes() {
let events = vec![called_tool_with_meta(
"Bash",
serde_json::json!({ "tool_input": { "command": "bun test --run" } }),
)];
let se = SideEffects::from_events(&events);
assert_eq!(se.processes.len(), 1);
assert_eq!(se.processes[0].command.as_deref(), Some("bun test --run"));
assert_eq!(se.processes[0].source.as_deref(), Some("mcp"));
}
#[test]
fn mcp_unknown_tool_with_path_defaults_to_files_written() {
let events = vec![called_tool_with_meta(
"mcp__weird-vendor__do_thing",
serde_json::json!({ "tool_input": { "file_path": "config.toml" } }),
)];
let se = SideEffects::from_events(&events);
assert_eq!(se.files_written.len(), 1);
assert_eq!(se.files_written[0].file_path, "config.toml");
assert_eq!(se.files_written[0].source.as_deref(), Some("mcp"));
}
#[test]
fn mcp_called_tool_without_meta_does_not_promote() {
let events = vec![called_tool_with_meta(
"ls",
serde_json::json!({"source": "mcp-bridge"}),
)];
let se = SideEffects::from_events(&events);
assert_eq!(se.files_read.len(), 0);
assert_eq!(se.files_written.len(), 0);
assert_eq!(se.processes.len(), 0);
assert_eq!(se.tool_invocations.len(), 1);
}
#[test]
fn mcp_promotion_handles_alt_path_field_names() {
for path_field in &[
"tool_input.path",
"tool_input.target_file",
"file_path",
"path",
] {
let mut meta_obj = serde_json::Map::new();
let parts: Vec<&str> = path_field.split('.').collect();
if parts.len() == 1 {
meta_obj.insert(parts[0].into(), serde_json::json!("x.txt"));
} else {
let inner = serde_json::json!({ parts[1]: "x.txt" });
meta_obj.insert(parts[0].into(), inner);
}
let events = vec![called_tool_with_meta(
"Edit",
serde_json::Value::Object(meta_obj),
)];
let se = SideEffects::from_events(&events);
assert_eq!(
se.files_written.len(),
1,
"expected promotion via {} but got nothing",
path_field,
);
}
}
fn evt_with_meta(et: EventType, meta: serde_json::Value) -> SessionEvent {
let mut e = evt(et);
e.meta = Some(meta);
e
}
#[test]
fn source_from_meta_preserves_session_event_cli() {
let events = vec![evt_with_meta(
EventType::AgentWroteFile {
file_path: "src/x.rs".into(),
digest: None,
operation: None,
additions: None,
deletions: None,
},
serde_json::json!({"source": "session-event-cli"}),
)];
let se = SideEffects::from_events(&events);
assert_eq!(
se.files_written[0].source.as_deref(),
Some("session-event-cli"),
"session-event-cli must be preserved verbatim, not downgraded to hook"
);
}
#[test]
fn source_from_meta_preserves_daemon_atime() {
let events = vec![evt_with_meta(
EventType::AgentReadFile {
file_path: "src/x.rs".into(),
digest: None,
},
serde_json::json!({"source": "daemon-atime"}),
)];
let se = SideEffects::from_events(&events);
assert_eq!(
se.files_read[0].source.as_deref(),
Some("daemon-atime"),
"daemon-atime must be preserved verbatim, not downgraded to hook"
);
}
#[test]
fn source_from_meta_preserves_arbitrary_unknown_label() {
let events = vec![evt_with_meta(
EventType::AgentWroteFile {
file_path: "x".into(),
digest: None,
operation: None,
additions: None,
deletions: None,
},
serde_json::json!({"source": "future-bridge-v2"}),
)];
let se = SideEffects::from_events(&events);
assert_eq!(
se.files_written[0].source.as_deref(),
Some("future-bridge-v2")
);
}
#[test]
fn source_from_meta_falls_back_when_meta_source_absent() {
let events = vec![evt(EventType::AgentWroteFile {
file_path: "x".into(),
digest: None,
operation: None,
additions: None,
deletions: None,
})];
let se = SideEffects::from_events(&events);
assert_eq!(se.files_written[0].source.as_deref(), Some("hook"));
}
#[test]
fn source_from_meta_treats_empty_string_as_absent() {
let events = vec![evt_with_meta(
EventType::AgentReadFile {
file_path: "x".into(),
digest: None,
},
serde_json::json!({"source": ""}),
)];
let se = SideEffects::from_events(&events);
assert_eq!(se.files_read[0].source.as_deref(), Some("hook"));
}
}