1use serde::{Deserialize, Serialize};
2use tokio::sync::mpsc::UnboundedSender;
3
4pub const STDERR_EVENT_PREFIX: &str = "__SAC_EVENT__";
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(tag = "type", rename_all = "snake_case")]
8pub enum AgentEvent {
9 RunStarted {
10 thread_name: Option<String>,
11 prompt_preview: String,
12 },
13 ModelCallStarted {
14 thread_name: Option<String>,
15 iteration: usize,
16 },
17 ToolCallStarted {
18 thread_name: Option<String>,
19 call_id: String,
20 name: String,
21 args_preview: String,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 args_detail: Option<String>,
24 },
25 ToolCallFinished {
26 thread_name: Option<String>,
27 call_id: String,
28 name: String,
29 content_preview: String,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 content: Option<String>,
32 is_error: bool,
33 },
34 ThreadStarted {
35 name: String,
36 action: String,
37 source_threads: Vec<String>,
38 },
39 ThreadSpawned {
40 name: String,
41 executable: String,
42 cwd: String,
43 sandboxed: bool,
44 },
45 ThreadLog {
46 name: String,
47 line: String,
48 },
49 TerminalSnapshot {
50 thread_name: Option<String>,
51 terminals: Vec<crate::terminal::TerminalInfo>,
52 },
53 ThreadFinished {
54 name: String,
55 exit_code: i32,
56 timed_out: bool,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 timeout_reason: Option<String>,
59 },
60 AssistantMessage {
61 thread_name: Option<String>,
62 content: String,
63 },
64 Error {
65 thread_name: Option<String>,
66 message: String,
67 },
68 RunFinished {
69 thread_name: Option<String>,
70 },
71 ModelIterationUsage {
76 thread_name: Option<String>,
77 iteration: usize,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 prompt_tokens: Option<u32>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 completion_tokens: Option<u32>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 total_tokens: Option<u32>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 cached_tokens: Option<u32>,
86 cumulative_usage: crate::types::Usage,
88 },
89 StreamTextDelta {
90 thread_name: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 text: Option<String>,
93 },
94 StreamComplete {
95 thread_name: Option<String>,
96 },
97 GoalContinuation {
101 continuation_turn: usize,
104 },
105 GoalTurnAccounted {
109 token_delta: i64,
110 time_delta_seconds: i64,
111 },
112 GoalErrorTransition {
115 new_status: String,
116 error_message: String,
117 },
118 LeanResumeTriggered {
119 thread_name: Option<String>,
120 reason: String,
121 },
122}
123
124#[derive(Clone, Default)]
125pub struct EventSink {
126 channel: Option<UnboundedSender<AgentEvent>>,
127 stderr_prefixed: bool,
128}
129
130impl EventSink {
131 pub fn none() -> Self {
132 Self::default()
133 }
134
135 pub fn channel(channel: UnboundedSender<AgentEvent>) -> Self {
136 Self {
137 channel: Some(channel),
138 stderr_prefixed: false,
139 }
140 }
141
142 pub fn stderr_prefixed() -> Self {
143 Self {
144 channel: None,
145 stderr_prefixed: true,
146 }
147 }
148
149 pub fn worker_stderr() -> Self {
150 Self::stderr_prefixed()
151 }
152
153 pub fn emit(&self, event: AgentEvent) {
154 if self.stderr_prefixed {
155 if let Ok(encoded) = serde_json::to_string(&event) {
156 eprintln!("{}{}", STDERR_EVENT_PREFIX, encoded);
157 }
158 }
159
160 if let Some(channel) = &self.channel {
161 let _ = channel.send(event);
162 }
163 }
164}
165
166pub fn emit_worker_stderr_event(event: &AgentEvent) {
167 if let Ok(encoded) = serde_json::to_string(event) {
168 eprintln!("{}{}", STDERR_EVENT_PREFIX, encoded);
169 }
170}
171
172pub fn decode_stderr_event(line: &str) -> Option<AgentEvent> {
173 let encoded = line.strip_prefix(STDERR_EVENT_PREFIX)?;
174 serde_json::from_str(encoded).ok()
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn decode_prefixed_event_round_trip() {
183 let event = AgentEvent::ThreadStarted {
184 name: "impl".to_string(),
185 action: "inspect auth".to_string(),
186 source_threads: vec!["auth".to_string()],
187 };
188 let encoded = format!(
189 "{}{}",
190 STDERR_EVENT_PREFIX,
191 serde_json::to_string(&event).unwrap()
192 );
193
194 let decoded = decode_stderr_event(&encoded).unwrap();
195 assert_eq!(decoded, event);
196 }
197
198 #[test]
199 fn decode_prefixed_thread_spawned_round_trip() {
200 let event = AgentEvent::ThreadSpawned {
201 name: "impl".to_string(),
202 executable: "/home/secemp9/.local/bin/sac".to_string(),
203 cwd: "/workspace/project".to_string(),
204 sandboxed: false,
205 };
206 let encoded = format!(
207 "{}{}",
208 STDERR_EVENT_PREFIX,
209 serde_json::to_string(&event).unwrap()
210 );
211
212 let decoded = decode_stderr_event(&encoded).unwrap();
213 assert_eq!(decoded, event);
214 }
215
216 #[test]
217 fn decode_prefixed_event_ignores_plain_lines() {
218 assert!(decode_stderr_event("plain stderr line").is_none());
219 assert!(decode_stderr_event("2026-01-01T00:00:00Z DEBUG sac::cli log line").is_none());
220 }
221
222 #[test]
223 fn worker_stderr_sink_is_prefixed() {
224 let sink = EventSink::worker_stderr();
225 assert!(sink.stderr_prefixed);
226 assert!(sink.channel.is_none());
227 }
228}