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