1use serde::Serialize;
20
21use cli_stream::ProcessEvent;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct ByteRange {
29 pub start: u64,
30 pub end: u64,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct SuggestedEdit {
38 pub file_path: String,
39 pub range: ByteRange,
40 pub replacement: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub title: Option<String>,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "camelCase")]
54#[non_exhaustive]
55pub enum ToolKind {
56 Read,
58 Write,
60 Edit,
62 Search,
64 Execute,
66 Other,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct ToolCallStart {
80 pub tool_call_id: String,
81 pub name: String,
82 pub input: Option<String>,
83 pub tool_kind: ToolKind,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
91#[serde(rename_all = "camelCase")]
92pub struct ToolCallEnd {
93 pub tool_call_id: String,
94 pub ok: bool,
95 pub output: Option<String>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
108#[non_exhaustive]
112pub enum RunEvent {
113 Started { run_id: String },
117 Session {
124 run_id: String,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 session_id: Option<String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 model: Option<String>,
129 },
130 Text { run_id: String, delta: String },
132 Thinking { run_id: String, delta: String },
136 ToolStart {
140 run_id: String,
141 tool_call_id: String,
142 name: String,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 input: Option<String>,
145 tool_kind: ToolKind,
146 },
147 ToolEnd {
150 run_id: String,
151 tool_call_id: String,
152 ok: bool,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 output: Option<String>,
155 },
156 SuggestedEdits {
158 run_id: String,
159 edits: Vec<SuggestedEdit>,
160 },
161 Activity { run_id: String, message: String },
164 Usage {
170 run_id: String,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 input_tokens: Option<u64>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 output_tokens: Option<u64>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 total_tokens: Option<u64>,
177 },
178 Error { run_id: String, message: String },
180 Exited {
182 run_id: String,
183 exit_code: Option<i32>,
184 cancelled: bool,
185 },
186}
187
188#[derive(Debug, Default, Clone, PartialEq, Eq)]
190pub struct SessionInfo {
191 pub session_id: Option<String>,
192 pub model: Option<String>,
193}
194
195#[derive(Debug, Default, Clone, PartialEq, Eq)]
197pub struct UsageInfo {
198 pub input_tokens: Option<u64>,
199 pub output_tokens: Option<u64>,
200 pub total_tokens: Option<u64>,
201}
202
203#[derive(Debug, Default, Clone, PartialEq, Eq)]
206pub struct ParsedLine {
207 pub text: Option<String>,
208 pub thinking: Option<String>,
211 pub session: Option<SessionInfo>,
213 pub tool_start: Option<ToolCallStart>,
215 pub tool_end: Option<ToolCallEnd>,
217 pub edits: Vec<SuggestedEdit>,
218 pub usage: Option<UsageInfo>,
220 pub activity: Option<String>,
221}
222
223impl ParsedLine {
224 pub fn is_empty(&self) -> bool {
228 self.text.is_none()
229 && self.thinking.is_none()
230 && self.session.is_none()
231 && self.tool_start.is_none()
232 && self.tool_end.is_none()
233 && self.edits.is_empty()
234 && self.usage.is_none()
235 && self.activity.is_none()
236 }
237}
238
239pub fn normalize_process_event(
246 event: ProcessEvent,
247 mut parse_line: impl FnMut(&str) -> ParsedLine,
248) -> Vec<RunEvent> {
249 match event {
250 ProcessEvent::Started { run_id } => vec![RunEvent::Started { run_id }],
251 ProcessEvent::Exited {
252 run_id,
253 exit_code,
254 cancelled,
255 } => vec![RunEvent::Exited {
256 run_id,
257 exit_code,
258 cancelled,
259 }],
260 ProcessEvent::Error { run_id, message } => vec![RunEvent::Error { run_id, message }],
261 ProcessEvent::Stderr { run_id, line } => {
262 let message = truncate(&line, 240);
265 if message.is_empty() {
266 vec![]
267 } else {
268 vec![RunEvent::Activity { run_id, message }]
269 }
270 }
271 ProcessEvent::Stdout { run_id, line } => run_events_from_parsed(&run_id, parse_line(&line)),
272 _ => Vec::new(),
275 }
276}
277
278pub fn run_events_from_parsed(run_id: &str, parsed: ParsedLine) -> Vec<RunEvent> {
293 let mut out = Vec::new();
294 if let Some(session) = parsed.session {
295 out.push(RunEvent::Session {
296 run_id: run_id.to_owned(),
297 session_id: session.session_id,
298 model: session.model,
299 });
300 }
301 if let Some(text) = parsed.text {
302 out.push(RunEvent::Text {
303 run_id: run_id.to_owned(),
304 delta: text,
305 });
306 }
307 if let Some(thinking) = parsed.thinking {
308 out.push(RunEvent::Thinking {
309 run_id: run_id.to_owned(),
310 delta: thinking,
311 });
312 }
313 if let Some(start) = parsed.tool_start {
314 out.push(RunEvent::ToolStart {
315 run_id: run_id.to_owned(),
316 tool_call_id: start.tool_call_id,
317 name: start.name,
318 input: start.input,
319 tool_kind: start.tool_kind,
320 });
321 }
322 if let Some(end) = parsed.tool_end {
323 out.push(RunEvent::ToolEnd {
324 run_id: run_id.to_owned(),
325 tool_call_id: end.tool_call_id,
326 ok: end.ok,
327 output: end.output,
328 });
329 }
330 if !parsed.edits.is_empty() {
331 out.push(RunEvent::SuggestedEdits {
332 run_id: run_id.to_owned(),
333 edits: parsed.edits,
334 });
335 }
336 if let Some(usage) = parsed.usage {
337 out.push(RunEvent::Usage {
338 run_id: run_id.to_owned(),
339 input_tokens: usage.input_tokens,
340 output_tokens: usage.output_tokens,
341 total_tokens: usage.total_tokens,
342 });
343 }
344 if let Some(activity) = parsed.activity {
345 out.push(RunEvent::Activity {
346 run_id: run_id.to_owned(),
347 message: activity,
348 });
349 }
350 out
351}
352
353fn truncate(s: &str, max_chars: usize) -> String {
356 s.chars().take(max_chars).collect()
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 fn empty_parser(_: &str) -> ParsedLine {
366 ParsedLine::default()
367 }
368
369 #[test]
370 fn normalize_passes_through_lifecycle_events() {
371 assert!(matches!(
372 normalize_process_event(ProcessEvent::Started { run_id: "r".into() }, empty_parser)
373 .as_slice(),
374 [RunEvent::Started { .. }]
375 ));
376 assert!(matches!(
377 normalize_process_event(
378 ProcessEvent::Exited {
379 run_id: "r".into(),
380 exit_code: Some(0),
381 cancelled: false
382 },
383 empty_parser
384 )
385 .as_slice(),
386 [RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }]
387 ));
388 }
389
390 #[test]
391 fn stderr_becomes_truncated_activity() {
392 let long = "x".repeat(500);
393 let events = normalize_process_event(
394 ProcessEvent::Stderr {
395 run_id: "r1".into(),
396 line: long,
397 },
398 empty_parser,
399 );
400 match events.as_slice() {
401 [RunEvent::Activity { run_id, message }] => {
402 assert_eq!(run_id, "r1");
403 assert_eq!(message.chars().count(), 240);
404 }
405 other => panic!("expected one Activity, got {other:?}"),
406 }
407 assert!(normalize_process_event(
409 ProcessEvent::Stderr {
410 run_id: "r1".into(),
411 line: String::new(),
412 },
413 empty_parser,
414 )
415 .is_empty());
416 }
417
418 #[test]
419 fn thinking_normalizes_and_serializes() {
420 let events = normalize_process_event(
421 ProcessEvent::Stdout {
422 run_id: "r1".to_owned(),
423 line: "ignored".to_owned(),
424 },
425 |_| ParsedLine {
426 thinking: Some("pondering".to_owned()),
427 ..ParsedLine::default()
428 },
429 );
430 assert!(matches!(
431 events.as_slice(),
432 [RunEvent::Thinking { run_id, delta }] if run_id == "r1" && delta == "pondering"
433 ));
434 let json = serde_json::to_value(RunEvent::Thinking {
435 run_id: "r1".to_owned(),
436 delta: "d".to_owned(),
437 })
438 .unwrap();
439 assert_eq!(json["kind"], "thinking");
440 assert_eq!(json["runId"], "r1");
441 assert_eq!(json["delta"], "d");
442 }
443
444 #[test]
445 fn run_event_serializes_with_kind_and_camelcase() {
446 let json = serde_json::to_value(RunEvent::Exited {
447 run_id: "r1".to_owned(),
448 exit_code: Some(2),
449 cancelled: true,
450 })
451 .unwrap();
452 assert_eq!(json["kind"], "exited");
453 assert_eq!(json["runId"], "r1");
454 assert_eq!(json["exitCode"], 2);
455 assert_eq!(json["cancelled"], true);
456 }
457
458 #[test]
459 fn session_normalizes_and_serializes() {
460 let events = normalize_process_event(
461 ProcessEvent::Stdout {
462 run_id: "r1".to_owned(),
463 line: "ignored".to_owned(),
464 },
465 |_| ParsedLine {
466 session: Some(SessionInfo {
467 session_id: Some("sess-1".to_owned()),
468 model: Some("opus".to_owned()),
469 }),
470 ..ParsedLine::default()
471 },
472 );
473 assert!(matches!(
474 events.as_slice(),
475 [RunEvent::Session { run_id, session_id, model }]
476 if run_id == "r1"
477 && session_id.as_deref() == Some("sess-1")
478 && model.as_deref() == Some("opus")
479 ));
480 let json = serde_json::to_value(RunEvent::Session {
481 run_id: "r1".to_owned(),
482 session_id: Some("sess-1".to_owned()),
483 model: None,
484 })
485 .unwrap();
486 assert_eq!(json["kind"], "session");
487 assert_eq!(json["sessionId"], "sess-1");
488 assert!(json.get("model").is_none());
490 }
491
492 #[test]
493 fn usage_normalizes_and_serializes() {
494 let events = normalize_process_event(
495 ProcessEvent::Stdout {
496 run_id: "r1".to_owned(),
497 line: "ignored".to_owned(),
498 },
499 |_| ParsedLine {
500 usage: Some(UsageInfo {
501 input_tokens: Some(10),
502 output_tokens: Some(20),
503 total_tokens: Some(30),
504 }),
505 ..ParsedLine::default()
506 },
507 );
508 assert!(matches!(
509 events.as_slice(),
510 [RunEvent::Usage { run_id, input_tokens: Some(10), output_tokens: Some(20), total_tokens: Some(30) }]
511 if run_id == "r1"
512 ));
513 let json = serde_json::to_value(RunEvent::Usage {
514 run_id: "r1".to_owned(),
515 input_tokens: Some(10),
516 output_tokens: None,
517 total_tokens: Some(30),
518 })
519 .unwrap();
520 assert_eq!(json["kind"], "usage");
521 assert_eq!(json["inputTokens"], 10);
522 assert_eq!(json["totalTokens"], 30);
523 assert!(json.get("outputTokens").is_none()); }
525
526 #[test]
527 fn tool_io_is_carried_and_omitted_when_absent() {
528 let start = normalize_process_event(
530 ProcessEvent::Stdout {
531 run_id: "r1".to_owned(),
532 line: "ignored".to_owned(),
533 },
534 |_| ParsedLine {
535 tool_start: Some(ToolCallStart {
536 tool_call_id: "t1".to_owned(),
537 name: "ls".to_owned(),
538 input: Some("{\"dir\":\"/x\"}".to_owned()),
539 tool_kind: ToolKind::Other,
540 }),
541 ..ParsedLine::default()
542 },
543 );
544 assert!(matches!(
545 start.as_slice(),
546 [RunEvent::ToolStart { input: Some(i), .. }] if i == "{\"dir\":\"/x\"}"
547 ));
548 let json = serde_json::to_value(RunEvent::ToolStart {
551 run_id: "r1".to_owned(),
552 tool_call_id: "t1".to_owned(),
553 name: "ls".to_owned(),
554 input: None,
555 tool_kind: ToolKind::Execute,
556 })
557 .unwrap();
558 assert_eq!(json["kind"], "toolStart");
559 assert_eq!(json["toolKind"], "execute");
562 assert_eq!(json["toolCallId"], "t1");
563 assert!(json.get("input").is_none());
564
565 let json = serde_json::to_value(RunEvent::ToolEnd {
566 run_id: "r1".to_owned(),
567 tool_call_id: "t1".to_owned(),
568 ok: true,
569 output: Some("done".to_owned()),
570 })
571 .unwrap();
572 assert_eq!(json["kind"], "toolEnd");
573 assert_eq!(json["output"], "done");
574 }
575
576 #[test]
577 fn suggested_edits_event_serializes_camelcase() {
578 let json = serde_json::to_value(RunEvent::SuggestedEdits {
579 run_id: "r1".to_owned(),
580 edits: vec![SuggestedEdit {
581 file_path: "a.md".to_owned(),
582 range: ByteRange { start: 1, end: 2 },
583 replacement: "x".to_owned(),
584 title: None,
585 }],
586 })
587 .unwrap();
588 assert_eq!(json["kind"], "suggestedEdits");
589 assert_eq!(json["edits"][0]["filePath"], "a.md");
590 assert_eq!(json["edits"][0]["range"]["start"], 1);
591 assert!(json["edits"][0].get("title").is_none());
593 }
594}