Skip to main content

sac/
events.rs

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    /// Emitted after each model iteration with per-iteration and cumulative
72    /// token usage.  Allows the TUI to perform incremental goal-budget
73    /// accounting and inject mid-turn steering messages when the budget is
74    /// exceeded.
75    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 across all iterations in this send() so far.
87        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    /// Emitted by the agent when it auto-continues for an active goal.
98    /// The agent handles continuation internally (engine-level), and this
99    /// event lets the TUI track each continuation turn for display.
100    GoalContinuation {
101        /// Zero-indexed continuation turn number (0 = first continuation
102        /// after the initial user-submitted turn).
103        continuation_turn: usize,
104    },
105    /// Emitted by the agent after each inner turn within a goal-driven
106    /// send to report token/time usage for that turn, so the TUI can
107    /// update its goal accounting.
108    GoalTurnAccounted {
109        token_delta: i64,
110        time_delta_seconds: i64,
111    },
112    /// Emitted when the agent encounters a turn error during goal
113    /// continuation and transitions the goal to an error state.
114    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}