1mod jsonl;
26mod mapping;
27
28use std::path::PathBuf;
29
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33pub use jsonl::JsonlWriter;
34
35pub const EVENT_SCHEMA_VERSION: u32 = 1;
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct EventLine {
43 pub seq: u64,
44 #[serde(flatten)]
45 pub event: Event,
46}
47
48impl EventLine {
49 pub fn new(seq: u64, event: Event) -> Self {
50 Self { seq, event }
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum Mutability {
58 ReadOnly,
59 Mutating,
60 Unknown,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum PermissionOutcome {
67 Allowed,
68 Denied,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum RuleScope {
75 Session,
76 Project,
77 Global,
78}
79
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum NoticeSeverity {
87 #[default]
88 Info,
89 Warning,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum TaskKind {
96 Subagent,
97 BackgroundTask,
98 Teammate,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum TaskStatus {
105 Spawned,
106 Running,
107 Finished,
108 Failed,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(tag = "status", rename_all = "snake_case")]
114#[non_exhaustive]
115pub enum RunOutcome {
116 Ok,
118 Error { message: String },
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct SkillSummary {
131 pub name: String,
132 pub description: String,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct TemplateSummary {
141 pub name: String,
142 pub description: String,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub argument_hint: Option<String>,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct ContextFile {
151 pub path: PathBuf,
152 pub scope: String,
153}
154
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160#[serde(tag = "type", rename_all = "snake_case")]
161#[non_exhaustive]
162pub enum Event {
163 RunStarted {
165 schema: u32,
166 basis: String,
167 session_id: String,
168 workspace: PathBuf,
169 model: String,
170 provider: String,
171 context_files: Vec<ContextFile>,
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
176 skills_dirs: Vec<PathBuf>,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 skills: Vec<SkillSummary>,
181 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 templates_dirs: Vec<PathBuf>,
184 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 templates: Vec<TemplateSummary>,
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
195 mcp_files: Vec<ContextFile>,
196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
200 mcp_servers: Vec<String>,
201 },
202
203 UserMessage {
204 text: String,
205 #[serde(default, skip_serializing_if = "is_zero")]
210 image_count: usize,
211 },
212 AssistantDelta {
213 text: String,
214 },
215 AssistantReasoningDelta {
216 text: String,
217 },
218 AssistantMessage {
219 text: String,
220 },
221
222 ToolQueued {
223 tool_call_id: String,
224 tool_name: String,
225 summary: String,
226 mutability: Mutability,
227 input: Value,
229 },
230 ToolStarted {
231 tool_call_id: String,
232 tool_name: String,
233 },
234 ToolProgress {
235 tool_call_id: String,
236 tool_name: String,
237 progress: String,
238 },
239 ToolCompleted {
240 tool_call_id: String,
241 tool_name: String,
242 summary: String,
243 is_error: bool,
244 },
245
246 PermissionRequested {
247 request_id: String,
248 tool_call_id: String,
249 tool_name: String,
250 description: String,
251 preview: Value,
253 },
254 PermissionResolved {
255 request_id: String,
256 tool_call_id: String,
257 tool_name: String,
258 outcome: PermissionOutcome,
259 #[serde(skip_serializing_if = "Option::is_none")]
260 rule_scope: Option<RuleScope>,
261 },
262
263 TaskUpdated {
264 task_id: String,
265 kind: TaskKind,
266 status: TaskStatus,
267 title: String,
268 #[serde(skip_serializing_if = "Option::is_none")]
269 detail: Option<String>,
270 },
271
272 CompactionStarted {
273 agent_id: String,
274 },
275 CompactionCompleted {
276 agent_id: String,
277 replaced_items: usize,
278 preserved_items: usize,
279 transcript_len: usize,
280 extracted_facts: usize,
281 summary_preview: String,
282 },
283 MemoryUpdated {
284 agent_id: String,
285 stored_records: usize,
286 },
287
288 Usage {
289 agent_id: String,
290 input_tokens: u64,
291 output_tokens: u64,
292 cache_read_tokens: u64,
293 cache_creation_tokens: u64,
294 #[serde(default)]
296 reasoning_tokens: u64,
297 #[serde(default)]
301 thoughts_tokens: u64,
302 },
303 Notice {
304 #[serde(default)]
309 severity: NoticeSeverity,
310 message: String,
311 },
312 Retry {
313 agent_id: String,
314 error: String,
315 attempt: u32,
316 max_attempts: u32,
317 next_delay_ms: u64,
318 },
319 Error {
320 message: String,
321 recoverable: bool,
322 },
323
324 Branched {
327 entry_id: String,
328 abandoned_entries: usize,
331 },
332
333 RunFinished {
335 #[serde(flatten)]
336 outcome: RunOutcome,
337 #[serde(skip_serializing_if = "Option::is_none", default)]
342 stopped_by: Option<crate::run::Bound>,
343 #[serde(skip_serializing_if = "Option::is_none", default)]
358 usage: Option<crate::run::RunUsage>,
359 },
360}
361
362impl Event {
363 pub fn from_session_event(event: &mentra::SessionEvent) -> Option<Self> {
366 mapping::from_session_event(event)
367 }
368
369 pub fn type_tag(&self) -> &'static str {
378 match self {
379 Event::RunStarted { .. } => "run_started",
380 Event::UserMessage { .. } => "user_message",
381 Event::AssistantDelta { .. } => "assistant_delta",
382 Event::AssistantReasoningDelta { .. } => "assistant_reasoning_delta",
383 Event::AssistantMessage { .. } => "assistant_message",
384 Event::ToolQueued { .. } => "tool_queued",
385 Event::ToolStarted { .. } => "tool_started",
386 Event::ToolProgress { .. } => "tool_progress",
387 Event::ToolCompleted { .. } => "tool_completed",
388 Event::PermissionRequested { .. } => "permission_requested",
389 Event::PermissionResolved { .. } => "permission_resolved",
390 Event::TaskUpdated { .. } => "task_updated",
391 Event::CompactionStarted { .. } => "compaction_started",
392 Event::CompactionCompleted { .. } => "compaction_completed",
393 Event::MemoryUpdated { .. } => "memory_updated",
394 Event::Usage { .. } => "usage",
395 Event::Notice { .. } => "notice",
396 Event::Retry { .. } => "retry",
397 Event::Error { .. } => "error",
398 Event::Branched { .. } => "branched",
399 Event::RunFinished { .. } => "run_finished",
400 }
401 }
402}
403
404fn is_zero(count: &usize) -> bool {
406 *count == 0
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
420 fn the_type_tag_is_the_tag_serde_writes() {
421 for event in [
422 Event::AssistantDelta {
423 text: "hi".to_string(),
424 },
425 Event::Notice {
426 severity: NoticeSeverity::Info,
427 message: "m".to_string(),
428 },
429 Event::RunFinished {
430 outcome: RunOutcome::Ok,
431 stopped_by: None,
432 usage: None,
433 },
434 ] {
435 let written = serde_json::to_value(&event).expect("serializes");
436 assert_eq!(written["type"].as_str().expect("tagged"), event.type_tag());
437 }
438 }
439
440 #[test]
441 fn a_line_is_one_flat_object() {
442 let line = EventLine::new(
443 7,
444 Event::AssistantDelta {
445 text: "hi".to_string(),
446 },
447 );
448 let json = serde_json::to_value(&line).expect("serializes");
449
450 assert_eq!(json["seq"], 7);
451 assert_eq!(json["type"], "assistant_delta");
452 assert_eq!(json["text"], "hi");
453 assert!(json.get("event").is_none(), "envelope must stay flat");
454 }
455
456 #[test]
457 fn the_header_carries_the_schema_version() {
458 let line = EventLine::new(
459 0,
460 Event::RunStarted {
461 schema: EVENT_SCHEMA_VERSION,
462 basis: "0.1.0".to_string(),
463 session_id: "s1".to_string(),
464 workspace: PathBuf::from("/repo"),
465 model: "gpt-5".to_string(),
466 provider: "openai".to_string(),
467 context_files: vec![ContextFile {
468 path: PathBuf::from("/repo/AGENTS.md"),
469 scope: "workspace".to_string(),
470 }],
471 skills_dirs: Vec::new(),
472 skills: Vec::new(),
473 templates_dirs: Vec::new(),
474 templates: Vec::new(),
475 mcp_files: Vec::new(),
476 mcp_servers: Vec::new(),
477 },
478 );
479 let json = serde_json::to_value(&line).expect("serializes");
480
481 assert_eq!(json["type"], "run_started");
482 assert_eq!(json["schema"], EVENT_SCHEMA_VERSION);
483 assert_eq!(json["context_files"][0]["scope"], "workspace");
484 assert!(
485 json.get("skills_dirs").is_none() && json.get("skills").is_none(),
486 "a run without skills must not mention them"
487 );
488 }
489
490 #[test]
491 fn skills_are_reported_when_there_are_any() {
492 let line = EventLine::new(
493 0,
494 Event::RunStarted {
495 schema: EVENT_SCHEMA_VERSION,
496 basis: "0.1.0".to_string(),
497 session_id: "s1".to_string(),
498 workspace: PathBuf::from("/repo"),
499 model: "gpt-5".to_string(),
500 provider: "openai".to_string(),
501 context_files: Vec::new(),
502 skills_dirs: vec![PathBuf::from("/repo/.basis/skills")],
503 skills: vec![SkillSummary {
504 name: "review".to_string(),
505 description: "house review style".to_string(),
506 }],
507 templates_dirs: Vec::new(),
508 templates: Vec::new(),
509 mcp_files: Vec::new(),
510 mcp_servers: Vec::new(),
511 },
512 );
513 let json = serde_json::to_value(&line).expect("serializes");
514
515 assert_eq!(json["skills_dirs"][0], "/repo/.basis/skills");
516 assert_eq!(json["skills"][0]["name"], "review");
517 assert!(
518 !json["skills"][0]
519 .as_object()
520 .expect("an object")
521 .contains_key("path"),
522 "the stream carries what the model can load, not where it lives on this machine"
523 );
524 }
525
526 #[test]
527 fn run_outcome_flattens_into_the_finish_line() {
528 let ok = serde_json::to_value(EventLine::new(
529 3,
530 Event::RunFinished {
531 outcome: RunOutcome::Ok,
532 stopped_by: None,
533 usage: None,
534 },
535 ))
536 .expect("serializes");
537 assert_eq!(ok["type"], "run_finished");
538 assert_eq!(ok["status"], "ok");
539 assert!(
540 !ok.as_object()
541 .expect("an object")
542 .contains_key("stopped_by"),
543 "an unbounded finish is byte-identical to what a schema-1 consumer already reads"
544 );
545
546 let failed = serde_json::to_value(EventLine::new(
547 3,
548 Event::RunFinished {
549 outcome: RunOutcome::Error {
550 message: "boom".to_string(),
551 },
552 stopped_by: None,
553 usage: None,
554 },
555 ))
556 .expect("serializes");
557 assert_eq!(failed["status"], "error");
558 assert_eq!(failed["message"], "boom");
559 }
560
561 #[test]
570 fn a_finish_line_reports_what_the_run_spent() {
571 let line = serde_json::to_value(EventLine::new(
572 4,
573 Event::RunFinished {
574 outcome: RunOutcome::Ok,
575 stopped_by: None,
576 usage: Some(crate::RunUsage {
577 input_tokens: 12_300,
578 output_tokens: 1_200,
579 cache_read_tokens: 40,
580 cache_creation_tokens: 5,
581 reasoning_tokens: 300,
582 thoughts_tokens: 0,
583 }),
584 },
585 ))
586 .expect("serializes");
587
588 assert_eq!(line["usage"]["input_tokens"], 12_300);
589 assert_eq!(line["usage"]["output_tokens"], 1_200);
590 assert_eq!(line["usage"]["cache_read_tokens"], 40);
591 assert_eq!(line["usage"]["cache_creation_tokens"], 5);
592
593 let unreported = serde_json::to_value(EventLine::new(
594 4,
595 Event::RunFinished {
596 outcome: RunOutcome::Ok,
597 stopped_by: None,
598 usage: None,
599 },
600 ))
601 .expect("serializes");
602 assert!(
603 !unreported
604 .as_object()
605 .expect("an object")
606 .contains_key("usage"),
607 "a producer that reported nothing says nothing, and the line keeps its schema-1 shape"
608 );
609
610 let read_back: EventLine =
611 serde_json::from_value(unreported).expect("a line without usage still parses");
612 assert!(matches!(
613 read_back.event,
614 Event::RunFinished { usage: None, .. }
615 ));
616 }
617
618 #[test]
619 fn a_bounded_finish_names_its_bound_on_the_stream() {
620 let line = serde_json::to_value(EventLine::new(
625 2,
626 Event::RunFinished {
627 outcome: RunOutcome::Ok,
628 stopped_by: Some(crate::run::Bound::TokenBudget),
629 usage: None,
630 },
631 ))
632 .expect("serializes");
633
634 assert_eq!(line["type"], "run_finished");
635 assert_eq!(line["status"], "ok");
636 assert_eq!(line["stopped_by"], "token_budget");
637 }
638
639 #[test]
640 fn the_header_names_mcp_files_and_servers_but_never_their_configuration() {
641 let line = EventLine::new(
642 0,
643 Event::RunStarted {
644 schema: EVENT_SCHEMA_VERSION,
645 basis: "0.1.0".to_string(),
646 session_id: "s1".to_string(),
647 workspace: PathBuf::from("/repo"),
648 model: "gpt-5".to_string(),
649 provider: "openai".to_string(),
650 context_files: Vec::new(),
651 skills_dirs: Vec::new(),
652 skills: Vec::new(),
653 templates_dirs: Vec::new(),
654 templates: Vec::new(),
655 mcp_files: vec![ContextFile {
656 path: PathBuf::from("/repo/.mcp.json"),
657 scope: "workspace".to_string(),
658 }],
659 mcp_servers: vec!["github".to_string()],
660 },
661 );
662 let text = serde_json::to_string(&line).expect("serializes");
663
664 assert!(text.contains("/repo/.mcp.json"), "the file must be named");
665 assert!(text.contains("github"), "so must the server");
666
667 for leak in ["command", "args", "env", "npx", "token"] {
673 assert!(
674 !text.contains(leak),
675 "the header must not carry MCP configuration, found {leak}: {text}"
676 );
677 }
678 }
679
680 #[test]
681 fn absent_optionals_are_omitted_not_null() {
682 let json = serde_json::to_value(EventLine::new(
683 1,
684 Event::TaskUpdated {
685 task_id: "t1".to_string(),
686 kind: TaskKind::Subagent,
687 status: TaskStatus::Running,
688 title: "work".to_string(),
689 detail: None,
690 },
691 ))
692 .expect("serializes");
693
694 assert!(json.get("detail").is_none());
695 }
696
697 #[test]
698 fn lines_round_trip() {
699 let line = EventLine::new(
700 2,
701 Event::ToolCompleted {
702 tool_call_id: "c1".to_string(),
703 tool_name: "shell".to_string(),
704 summary: "ok".to_string(),
705 is_error: false,
706 },
707 );
708 let text = serde_json::to_string(&line).expect("serializes");
709 let back: EventLine = serde_json::from_str(&text).expect("deserializes");
710
711 assert_eq!(line, back);
712 }
713}