1use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10use super::event::{EventType, SessionEvent};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct FileAccess {
15 pub file_path: String,
16 pub agent_instance_id: String,
17 pub timestamp: String,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub digest: Option<String>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PortAccess {
25 pub port: u16,
26 pub agent_instance_id: String,
27 pub timestamp: String,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub protocol: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct NetworkConnection {
35 pub destination: String,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub port: Option<u16>,
38 pub agent_instance_id: String,
39 pub timestamp: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ProcessExecution {
45 pub process_name: String,
46 pub agent_instance_id: String,
47 pub started_at: String,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub exit_code: Option<i32>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub duration_ms: Option<u64>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ToolInvocation {
57 pub tool_name: String,
58 pub agent_instance_id: String,
59 pub timestamp: String,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub duration_ms: Option<u64>,
62}
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct SideEffects {
67 pub files_read: Vec<FileAccess>,
68 pub files_written: Vec<FileAccess>,
69 pub ports_opened: Vec<PortAccess>,
70 pub network_connections: Vec<NetworkConnection>,
71 pub processes: Vec<ProcessExecution>,
72 pub tool_invocations: Vec<ToolInvocation>,
73}
74
75impl SideEffects {
76 pub fn from_events(events: &[SessionEvent]) -> Self {
78 let mut se = SideEffects::default();
79
80 let mut started_processes: BTreeMap<(String, String), usize> = BTreeMap::new();
83
84 for event in events {
85 match &event.event_type {
86 EventType::AgentReadFile { file_path, digest } => {
87 se.files_read.push(FileAccess {
88 file_path: file_path.clone(),
89 agent_instance_id: event.agent_instance_id.clone(),
90 timestamp: event.timestamp.clone(),
91 digest: digest.clone(),
92 });
93 }
94
95 EventType::AgentWroteFile { file_path, digest } => {
96 se.files_written.push(FileAccess {
97 file_path: file_path.clone(),
98 agent_instance_id: event.agent_instance_id.clone(),
99 timestamp: event.timestamp.clone(),
100 digest: digest.clone(),
101 });
102 }
103
104 EventType::AgentOpenedPort { port, protocol } => {
105 se.ports_opened.push(PortAccess {
106 port: *port,
107 agent_instance_id: event.agent_instance_id.clone(),
108 timestamp: event.timestamp.clone(),
109 protocol: protocol.clone(),
110 });
111 }
112
113 EventType::AgentConnectedNetwork { destination, port } => {
114 se.network_connections.push(NetworkConnection {
115 destination: destination.clone(),
116 port: *port,
117 agent_instance_id: event.agent_instance_id.clone(),
118 timestamp: event.timestamp.clone(),
119 });
120 }
121
122 EventType::AgentStartedProcess { process_name, pid: _ } => {
123 let idx = se.processes.len();
124 se.processes.push(ProcessExecution {
125 process_name: process_name.clone(),
126 agent_instance_id: event.agent_instance_id.clone(),
127 started_at: event.timestamp.clone(),
128 exit_code: None,
129 duration_ms: None,
130 });
131 started_processes.insert(
132 (event.agent_instance_id.clone(), process_name.clone()),
133 idx,
134 );
135 }
136
137 EventType::AgentCompletedProcess { process_name, exit_code, duration_ms } => {
138 let key = (event.agent_instance_id.clone(), process_name.clone());
139 if let Some(&idx) = started_processes.get(&key) {
140 if let Some(proc) = se.processes.get_mut(idx) {
141 proc.exit_code = *exit_code;
142 proc.duration_ms = *duration_ms;
143 }
144 } else {
145 se.processes.push(ProcessExecution {
147 process_name: process_name.clone(),
148 agent_instance_id: event.agent_instance_id.clone(),
149 started_at: event.timestamp.clone(),
150 exit_code: *exit_code,
151 duration_ms: *duration_ms,
152 });
153 }
154 }
155
156 EventType::AgentCalledTool { tool_name, duration_ms, .. } => {
157 se.tool_invocations.push(ToolInvocation {
158 tool_name: tool_name.clone(),
159 agent_instance_id: event.agent_instance_id.clone(),
160 timestamp: event.timestamp.clone(),
161 duration_ms: *duration_ms,
162 });
163 }
164
165 _ => {}
166 }
167 }
168
169 se
170 }
171
172 pub fn summary(&self) -> SideEffectSummary {
174 SideEffectSummary {
175 files_read: self.files_read.len() as u32,
176 files_written: self.files_written.len() as u32,
177 ports_opened: self.ports_opened.len() as u32,
178 network_connections: self.network_connections.len() as u32,
179 processes: self.processes.len() as u32,
180 tool_invocations: self.tool_invocations.len() as u32,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SideEffectSummary {
188 pub files_read: u32,
189 pub files_written: u32,
190 pub ports_opened: u32,
191 pub network_connections: u32,
192 pub processes: u32,
193 pub tool_invocations: u32,
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::session::event::*;
200
201 fn evt(event_type: EventType) -> SessionEvent {
202 SessionEvent {
203 session_id: "ssn_001".into(),
204 event_id: generate_event_id(),
205 timestamp: "2026-04-05T08:00:00Z".into(),
206 sequence_no: 0,
207 trace_id: "t".into(),
208 span_id: "s".into(),
209 parent_span_id: None,
210 agent_id: "agent://test".into(),
211 agent_instance_id: "ai_1".into(),
212 agent_name: "test".into(),
213 agent_role: None,
214 host_id: "h".into(),
215 tool_runtime_id: None,
216 event_type,
217 artifact_ref: None,
218 meta: None,
219 }
220 }
221
222 #[test]
223 fn aggregates_file_and_tool_events() {
224 let events = vec![
225 evt(EventType::AgentReadFile { file_path: "src/main.rs".into(), digest: None }),
226 evt(EventType::AgentWroteFile { file_path: "src/lib.rs".into(), digest: Some("sha256:abc".into()) }),
227 evt(EventType::AgentCalledTool { tool_name: "read_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: Some(10) }),
228 evt(EventType::AgentCalledTool { tool_name: "write_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: None }),
229 ];
230
231 let se = SideEffects::from_events(&events);
232 assert_eq!(se.files_read.len(), 1);
233 assert_eq!(se.files_written.len(), 1);
234 assert_eq!(se.tool_invocations.len(), 2);
235 let summary = se.summary();
236 assert_eq!(summary.tool_invocations, 2);
237 }
238
239 #[test]
240 fn matches_process_start_and_complete() {
241 let events = vec![
242 evt(EventType::AgentStartedProcess { process_name: "npm test".into(), pid: Some(1234) }),
243 evt(EventType::AgentCompletedProcess { process_name: "npm test".into(), exit_code: Some(0), duration_ms: Some(5000) }),
244 ];
245
246 let se = SideEffects::from_events(&events);
247 assert_eq!(se.processes.len(), 1);
248 assert_eq!(se.processes[0].exit_code, Some(0));
249 assert_eq!(se.processes[0].duration_ms, Some(5000));
250 }
251}