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"));
}
}