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, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum NoticeSeverity {
84 Info,
85 Warning,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum TaskKind {
92 Subagent,
93 BackgroundTask,
94 Teammate,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum TaskStatus {
101 Spawned,
102 Running,
103 Finished,
104 Failed,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(tag = "status", rename_all = "snake_case")]
110pub enum RunOutcome {
111 Ok,
113 Error { message: String },
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct SkillSummary {
126 pub name: String,
127 pub description: String,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct TemplateSummary {
136 pub name: String,
137 pub description: String,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub argument_hint: Option<String>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct ContextFile {
146 pub path: PathBuf,
147 pub scope: String,
148}
149
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155#[serde(tag = "type", rename_all = "snake_case")]
156pub enum Event {
157 RunStarted {
159 schema: u32,
160 basis: String,
161 session_id: String,
162 workspace: PathBuf,
163 model: String,
164 provider: String,
165 context_files: Vec<ContextFile>,
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
170 skills_dirs: Vec<PathBuf>,
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 skills: Vec<SkillSummary>,
175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 templates_dirs: Vec<PathBuf>,
178 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 templates: Vec<TemplateSummary>,
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
189 mcp_files: Vec<ContextFile>,
190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
194 mcp_servers: Vec<String>,
195 },
196
197 UserMessage {
198 text: String,
199 },
200 AssistantDelta {
201 text: String,
202 },
203 AssistantReasoningDelta {
204 text: String,
205 },
206 AssistantMessage {
207 text: String,
208 },
209
210 ToolQueued {
211 tool_call_id: String,
212 tool_name: String,
213 summary: String,
214 mutability: Mutability,
215 input: Value,
217 },
218 ToolStarted {
219 tool_call_id: String,
220 tool_name: String,
221 },
222 ToolProgress {
223 tool_call_id: String,
224 tool_name: String,
225 progress: String,
226 },
227 ToolCompleted {
228 tool_call_id: String,
229 tool_name: String,
230 summary: String,
231 is_error: bool,
232 },
233
234 PermissionRequested {
235 request_id: String,
236 tool_call_id: String,
237 tool_name: String,
238 description: String,
239 preview: Value,
241 },
242 PermissionResolved {
243 request_id: String,
244 tool_call_id: String,
245 tool_name: String,
246 outcome: PermissionOutcome,
247 #[serde(skip_serializing_if = "Option::is_none")]
248 rule_scope: Option<RuleScope>,
249 },
250
251 TaskUpdated {
252 task_id: String,
253 kind: TaskKind,
254 status: TaskStatus,
255 title: String,
256 #[serde(skip_serializing_if = "Option::is_none")]
257 detail: Option<String>,
258 },
259
260 CompactionStarted {
261 agent_id: String,
262 },
263 CompactionCompleted {
264 agent_id: String,
265 replaced_items: usize,
266 preserved_items: usize,
267 transcript_len: usize,
268 extracted_facts: usize,
269 summary_preview: String,
270 },
271 MemoryUpdated {
272 agent_id: String,
273 stored_records: usize,
274 },
275
276 Usage {
277 agent_id: String,
278 input_tokens: u64,
279 output_tokens: u64,
280 cache_read_tokens: u64,
281 cache_creation_tokens: u64,
282 },
283 Notice {
284 severity: NoticeSeverity,
285 message: String,
286 },
287 Retry {
288 agent_id: String,
289 error: String,
290 attempt: u32,
291 max_attempts: u32,
292 next_delay_ms: u64,
293 },
294 Error {
295 message: String,
296 recoverable: bool,
297 },
298
299 Branched {
302 entry_id: String,
303 abandoned_entries: usize,
306 },
307
308 RunFinished {
310 #[serde(flatten)]
311 outcome: RunOutcome,
312 #[serde(skip_serializing_if = "Option::is_none", default)]
317 stopped_by: Option<crate::run::Bound>,
318 },
319}
320
321impl Event {
322 pub fn from_session_event(event: &mentra::SessionEvent) -> Option<Self> {
325 mapping::from_session_event(event)
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn a_line_is_one_flat_object() {
335 let line = EventLine::new(
336 7,
337 Event::AssistantDelta {
338 text: "hi".to_string(),
339 },
340 );
341 let json = serde_json::to_value(&line).expect("serializes");
342
343 assert_eq!(json["seq"], 7);
344 assert_eq!(json["type"], "assistant_delta");
345 assert_eq!(json["text"], "hi");
346 assert!(json.get("event").is_none(), "envelope must stay flat");
347 }
348
349 #[test]
350 fn the_header_carries_the_schema_version() {
351 let line = EventLine::new(
352 0,
353 Event::RunStarted {
354 schema: EVENT_SCHEMA_VERSION,
355 basis: "0.1.0".to_string(),
356 session_id: "s1".to_string(),
357 workspace: PathBuf::from("/repo"),
358 model: "gpt-5".to_string(),
359 provider: "openai".to_string(),
360 context_files: vec![ContextFile {
361 path: PathBuf::from("/repo/AGENTS.md"),
362 scope: "workspace".to_string(),
363 }],
364 skills_dirs: Vec::new(),
365 skills: Vec::new(),
366 templates_dirs: Vec::new(),
367 templates: Vec::new(),
368 mcp_files: Vec::new(),
369 mcp_servers: Vec::new(),
370 },
371 );
372 let json = serde_json::to_value(&line).expect("serializes");
373
374 assert_eq!(json["type"], "run_started");
375 assert_eq!(json["schema"], EVENT_SCHEMA_VERSION);
376 assert_eq!(json["context_files"][0]["scope"], "workspace");
377 assert!(
378 json.get("skills_dirs").is_none() && json.get("skills").is_none(),
379 "a run without skills must not mention them"
380 );
381 }
382
383 #[test]
384 fn skills_are_reported_when_there_are_any() {
385 let line = EventLine::new(
386 0,
387 Event::RunStarted {
388 schema: EVENT_SCHEMA_VERSION,
389 basis: "0.1.0".to_string(),
390 session_id: "s1".to_string(),
391 workspace: PathBuf::from("/repo"),
392 model: "gpt-5".to_string(),
393 provider: "openai".to_string(),
394 context_files: Vec::new(),
395 skills_dirs: vec![PathBuf::from("/repo/.basis/skills")],
396 skills: vec![SkillSummary {
397 name: "review".to_string(),
398 description: "house review style".to_string(),
399 }],
400 templates_dirs: Vec::new(),
401 templates: Vec::new(),
402 mcp_files: Vec::new(),
403 mcp_servers: Vec::new(),
404 },
405 );
406 let json = serde_json::to_value(&line).expect("serializes");
407
408 assert_eq!(json["skills_dirs"][0], "/repo/.basis/skills");
409 assert_eq!(json["skills"][0]["name"], "review");
410 assert!(
411 !json["skills"][0]
412 .as_object()
413 .expect("an object")
414 .contains_key("path"),
415 "the stream carries what the model can load, not where it lives on this machine"
416 );
417 }
418
419 #[test]
420 fn run_outcome_flattens_into_the_finish_line() {
421 let ok = serde_json::to_value(EventLine::new(
422 3,
423 Event::RunFinished {
424 outcome: RunOutcome::Ok,
425 stopped_by: None,
426 },
427 ))
428 .expect("serializes");
429 assert_eq!(ok["type"], "run_finished");
430 assert_eq!(ok["status"], "ok");
431 assert!(
432 !ok.as_object()
433 .expect("an object")
434 .contains_key("stopped_by"),
435 "an unbounded finish is byte-identical to what a schema-1 consumer already reads"
436 );
437
438 let failed = serde_json::to_value(EventLine::new(
439 3,
440 Event::RunFinished {
441 outcome: RunOutcome::Error {
442 message: "boom".to_string(),
443 },
444 stopped_by: None,
445 },
446 ))
447 .expect("serializes");
448 assert_eq!(failed["status"], "error");
449 assert_eq!(failed["message"], "boom");
450 }
451
452 #[test]
453 fn a_bounded_finish_names_its_bound_on_the_stream() {
454 let line = serde_json::to_value(EventLine::new(
459 2,
460 Event::RunFinished {
461 outcome: RunOutcome::Ok,
462 stopped_by: Some(crate::run::Bound::TokenBudget),
463 },
464 ))
465 .expect("serializes");
466
467 assert_eq!(line["type"], "run_finished");
468 assert_eq!(line["status"], "ok");
469 assert_eq!(line["stopped_by"], "token_budget");
470 }
471
472 #[test]
473 fn the_header_names_mcp_files_and_servers_but_never_their_configuration() {
474 let line = EventLine::new(
475 0,
476 Event::RunStarted {
477 schema: EVENT_SCHEMA_VERSION,
478 basis: "0.1.0".to_string(),
479 session_id: "s1".to_string(),
480 workspace: PathBuf::from("/repo"),
481 model: "gpt-5".to_string(),
482 provider: "openai".to_string(),
483 context_files: Vec::new(),
484 skills_dirs: Vec::new(),
485 skills: Vec::new(),
486 templates_dirs: Vec::new(),
487 templates: Vec::new(),
488 mcp_files: vec![ContextFile {
489 path: PathBuf::from("/repo/.mcp.json"),
490 scope: "workspace".to_string(),
491 }],
492 mcp_servers: vec!["github".to_string()],
493 },
494 );
495 let text = serde_json::to_string(&line).expect("serializes");
496
497 assert!(text.contains("/repo/.mcp.json"), "the file must be named");
498 assert!(text.contains("github"), "so must the server");
499
500 for leak in ["command", "args", "env", "npx", "token"] {
506 assert!(
507 !text.contains(leak),
508 "the header must not carry MCP configuration, found {leak}: {text}"
509 );
510 }
511 }
512
513 #[test]
514 fn absent_optionals_are_omitted_not_null() {
515 let json = serde_json::to_value(EventLine::new(
516 1,
517 Event::TaskUpdated {
518 task_id: "t1".to_string(),
519 kind: TaskKind::Subagent,
520 status: TaskStatus::Running,
521 title: "work".to_string(),
522 detail: None,
523 },
524 ))
525 .expect("serializes");
526
527 assert!(json.get("detail").is_none());
528 }
529
530 #[test]
531 fn lines_round_trip() {
532 let line = EventLine::new(
533 2,
534 Event::ToolCompleted {
535 tool_call_id: "c1".to_string(),
536 tool_name: "shell".to_string(),
537 summary: "ok".to_string(),
538 is_error: false,
539 },
540 );
541 let text = serde_json::to_string(&line).expect("serializes");
542 let back: EventLine = serde_json::from_str(&text).expect("deserializes");
543
544 assert_eq!(line, back);
545 }
546}