1use crate::notify::{NotifyLevel, NotifyLifecycle, NotifyLocation, NotifyStack};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct NotificationFrame {
6 pub level: NotifyLevel,
7 pub location: NotifyLocation,
8 pub lifecycle: NotifyLifecycle,
9 pub stack: NotifyStack,
10 pub message: String,
11}
12
13impl From<crate::notify::Notification> for NotificationFrame {
14 fn from(n: crate::notify::Notification) -> Self {
15 Self {
16 level: n.level,
17 location: n.location,
18 lifecycle: n.lifecycle,
19 stack: n.stack,
20 message: n.message,
21 }
22 }
23}
24
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
26#[serde(rename_all = "snake_case")]
27pub enum CompactionPhase {
28 Running,
29 Finished,
30 Failed,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub enum StreamFrame {
35 LlmChunk {
36 text: String,
37 model: String,
38 #[serde(default)]
39 run_id: Option<String>,
40 },
41 ThinkingChunk {
42 text: String,
43 #[serde(default)]
44 run_id: Option<String>,
45 },
46 LlmDone {
47 total_tokens: u64,
48 #[serde(default)]
49 run_id: Option<String>,
50 },
51 LlmRetry,
53 LlmCallStats {
54 model: String,
55 input_tokens: u64,
56 output_tokens: u64,
57 cache_read: u64,
58 cache_write: u64,
59 ttft_ms: u64,
60 tokens_per_second: f64,
61 wallclock_ms: u64,
62 run_id: Option<String>,
63 node_id: Option<String>,
64 },
65 ToolUseStart {
66 tool: String,
67 args_preview: String,
68 id: String,
69 },
70 ToolUseDone {
71 tool: String,
72 ok: bool,
73 preview: String,
74 id: String,
75 },
76 Note(String),
77 Notification(NotificationFrame),
79 FlowGraph {
80 run_id: String,
81 graph: crate::nodegraph::FlowGraph,
82 },
83 FlowStart {
84 run_id: String,
85 flow_name: String,
86 #[serde(default)]
87 parent_run_id: Option<String>,
88 #[serde(default)]
89 parent_node_id: Option<String>,
90 },
91 FlowNodeStart {
92 run_id: String,
93 node_id: String,
94 kind: crate::nodegraph::NodeKind,
95 label: String,
96 #[serde(default)]
97 parent_node_id: Option<String>,
98 },
99 FlowNodeEnd {
100 run_id: String,
101 node_id: String,
102 status: crate::event::FlowNodeStatus,
103 output_preview: Option<String>,
104 #[serde(default)]
105 parent_node_id: Option<String>,
106 },
107 FlowDone {
108 run_id: String,
109 flow_name: String,
110 ok: bool,
111 #[serde(default)]
112 cancelled: bool,
113 },
114 ToolNode {
115 run_id: String,
116 parent_node_id: String,
117 tool_use_id: String,
118 tool: String,
119 args_preview: String,
120 },
121 AssistantMsg {
122 flow_run_id: Option<String>,
123 message: crate::message::Message,
124 },
125 ToolResultMsg {
126 flow_run_id: Option<String>,
127 message: crate::message::Message,
128 },
129 ToolPendingApproval {
130 run_id: String,
131 tool_use_id: String,
132 tool_name: String,
133 args_preview: String,
134 level: String,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 preview: Option<String>,
137 },
138 ToolApproved {
139 run_id: String,
140 tool_use_id: String,
141 decided_by: String,
142 },
143 ToolDenied {
144 run_id: String,
145 tool_use_id: String,
146 reason: String,
147 },
148 TerminalChunk {
149 handle: String,
150 bytes: Vec<u8>,
151 screen: Option<crate::tools::term::TerminalScreen>,
152 state: crate::tools::term::TermStateSnapshot,
153 #[serde(default)]
154 run_id: Option<String>,
155 },
156 TerminalExited {
157 handle: String,
158 exit_code: Option<i32>,
159 #[serde(default)]
160 run_id: Option<String>,
161 },
162 BashChunk {
163 handle: String,
164 kind: String,
165 line: String,
166 #[serde(default)]
167 run_id: Option<String>,
168 },
169 BashExited {
170 handle: String,
171 exit_code: Option<i32>,
172 #[serde(default)]
173 run_id: Option<String>,
174 },
175 DiffPreview {
176 title: String,
177 old_content: Option<String>,
178 new_content: Option<String>,
179 unified_diff: Option<String>,
180 #[serde(default)]
181 run_id: Option<String>,
182 },
183 CompactionSummary {
184 phase: CompactionPhase,
185 range_start: usize,
186 range_end: usize,
187 summary: String,
188 before_tokens: u64,
189 after_tokens: u64,
190 compacted_count: usize,
191 },
192 MermaidDiagram {
193 source: String,
194 },
195 SubAgentStarted {
196 handle: String,
197 goal: String,
198 child_run_id: String,
199 model: String,
200 },
201 SubAgentDone {
202 handle: String,
203 status: String,
204 final_text: String,
205 },
206 #[serde(other)]
207 Unknown,
208}
209
210pub fn frame_run_id(frame: &StreamFrame) -> Option<&str> {
213 match frame {
214 StreamFrame::FlowStart { run_id, .. }
215 | StreamFrame::FlowNodeStart { run_id, .. }
216 | StreamFrame::FlowNodeEnd { run_id, .. }
217 | StreamFrame::FlowDone { run_id, .. }
218 | StreamFrame::FlowGraph { run_id, .. }
219 | StreamFrame::ToolNode { run_id, .. } => Some(run_id.as_str()),
220 StreamFrame::AssistantMsg {
221 flow_run_id: Some(rid),
222 ..
223 }
224 | StreamFrame::ToolResultMsg {
225 flow_run_id: Some(rid),
226 ..
227 }
228 | StreamFrame::LlmCallStats {
229 run_id: Some(rid), ..
230 } => Some(rid.as_str()),
231 StreamFrame::LlmChunk {
232 run_id: Some(rid), ..
233 }
234 | StreamFrame::ThinkingChunk {
235 run_id: Some(rid), ..
236 }
237 | StreamFrame::LlmDone {
238 run_id: Some(rid), ..
239 }
240 | StreamFrame::TerminalChunk {
241 run_id: Some(rid), ..
242 }
243 | StreamFrame::TerminalExited {
244 run_id: Some(rid), ..
245 }
246 | StreamFrame::BashChunk {
247 run_id: Some(rid), ..
248 }
249 | StreamFrame::BashExited {
250 run_id: Some(rid), ..
251 }
252 | StreamFrame::DiffPreview {
253 run_id: Some(rid), ..
254 } => Some(rid.as_str()),
255 _ => None,
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn tool_node_round_trips() {
265 let f = StreamFrame::ToolNode {
266 run_id: "r".into(),
267 parent_node_id: "stmt_0".into(),
268 tool_use_id: "tu_1".into(),
269 tool: "fs.read".into(),
270 args_preview: "{}".into(),
271 };
272 let json = serde_json::to_string(&f).unwrap();
273 let back: StreamFrame = serde_json::from_str(&json).unwrap();
274 assert!(matches!(back, StreamFrame::ToolNode { .. }));
275 }
276
277 #[test]
278 fn flow_node_start_serde_carries_parent() {
279 let f = StreamFrame::FlowNodeStart {
280 run_id: "r".into(),
281 node_id: "stmt_1.branch[0]".into(),
282 kind: crate::nodegraph::NodeKind::UserConfirm,
283 label: "b".into(),
284 parent_node_id: Some("stmt_1".into()),
285 };
286 let json = serde_json::to_string(&f).unwrap();
287 assert!(json.contains("\"parent_node_id\":\"stmt_1\""));
288 let back: StreamFrame = serde_json::from_str(&json).unwrap();
289 if let StreamFrame::FlowNodeStart { parent_node_id, .. } = back {
290 assert_eq!(parent_node_id.as_deref(), Some("stmt_1"));
291 } else {
292 panic!("wrong variant");
293 }
294 }
295
296 #[test]
297 fn unknown_bare_variant_falls_back() {
298 let payload = r#""SomeFutureFrame""#;
299 let back: StreamFrame = serde_json::from_str(payload).unwrap();
300 assert!(matches!(back, StreamFrame::Unknown));
301 }
302
303 #[test]
304 fn terminal_chunk_round_trips() {
305 let screen = crate::tools::term::TerminalScreen {
306 rows: 2,
307 cols: 3,
308 cells: vec![
309 crate::tools::term::TerminalCell {
310 chars: "A".into(),
311 ..Default::default()
312 },
313 crate::tools::term::TerminalCell::default(),
314 crate::tools::term::TerminalCell::default(),
315 crate::tools::term::TerminalCell::default(),
316 crate::tools::term::TerminalCell::default(),
317 crate::tools::term::TerminalCell::default(),
318 ],
319 cursor: Some((0, 0)),
320 alt_screen: false,
321 };
322 let f = StreamFrame::TerminalChunk {
323 handle: "term_s_0".into(),
324 bytes: b"hi".to_vec(),
325 screen: Some(screen),
326 state: crate::tools::term::TermStateSnapshot::Running,
327 run_id: None,
328 };
329 let json = serde_json::to_string(&f).unwrap();
330 let back: StreamFrame = serde_json::from_str(&json).unwrap();
331 match back {
332 StreamFrame::TerminalChunk {
333 handle,
334 bytes,
335 screen,
336 state,
337 run_id,
338 } => {
339 assert_eq!(handle, "term_s_0");
340 assert_eq!(bytes, b"hi");
341 assert!(run_id.is_none());
342 let screen = screen.expect("screen should be Some");
343 assert_eq!(screen.rows, 2);
344 assert_eq!(screen.cols, 3);
345 assert_eq!(screen.cells.len(), 6);
346 assert_eq!(screen.cells[0].chars, "A");
347 assert!(matches!(
348 state,
349 crate::tools::term::TermStateSnapshot::Running
350 ));
351 }
352 _ => panic!("wrong variant"),
353 }
354 }
355
356 #[test]
357 fn terminal_exited_round_trips() {
358 let f = StreamFrame::TerminalExited {
359 handle: "term_s_1".into(),
360 exit_code: Some(0),
361 run_id: None,
362 };
363 let json = serde_json::to_string(&f).unwrap();
364 let back: StreamFrame = serde_json::from_str(&json).unwrap();
365 match back {
366 StreamFrame::TerminalExited {
367 handle, exit_code, ..
368 } => {
369 assert_eq!(handle, "term_s_1");
370 assert_eq!(exit_code, Some(0));
371 }
372 _ => panic!("wrong variant"),
373 }
374 }
375
376 #[test]
377 fn compaction_summary_round_trips() {
378 let f = StreamFrame::CompactionSummary {
379 phase: CompactionPhase::Running,
380 range_start: 3,
381 range_end: 11,
382 summary: String::new(),
383 before_tokens: 42,
384 after_tokens: 0,
385 compacted_count: 8,
386 };
387 let json = serde_json::to_string(&f).unwrap();
388 let back: StreamFrame = serde_json::from_str(&json).unwrap();
389 match back {
390 StreamFrame::CompactionSummary {
391 phase,
392 range_start,
393 range_end,
394 compacted_count,
395 ..
396 } => {
397 assert_eq!(phase, CompactionPhase::Running);
398 assert_eq!(range_start, 3);
399 assert_eq!(range_end, 11);
400 assert_eq!(compacted_count, 8);
401 }
402 _ => panic!("wrong variant"),
403 }
404 }
405}