Skip to main content

basis/
event.rs

1//! basis's event stream: one schema, many surfaces.
2//!
3//! Mentra's [`SessionEvent`] broadcast is the source of truth for what happens
4//! during a run. This module normalizes it into a wire contract basis owns, so
5//! that `basis spawn --json` (with `basis run` retained as an alias), the ACP
6//! mapping (P2), and anything downstream all read the same shape — and so a
7//! change inside mentra does not silently
8//! become a change in basis's output.
9//!
10//! # Wire format
11//!
12//! Newline-delimited JSON, one [`EventLine`] per line. The first line is
13//! always [`Event::RunStarted`], which carries [`EVENT_SCHEMA_VERSION`]; a
14//! consumer reads the version before anything else and can refuse a stream it
15//! does not understand. The last line is always [`Event::RunFinished`].
16//!
17//! ```jsonl
18//! {"seq":0,"type":"run_started","schema":1,"basis":"0.1.0","workspace":"/repo",...}
19//! {"seq":1,"type":"assistant_delta","text":"Looking at "}
20//! {"seq":2,"type":"run_finished","status":"ok"}
21//! ```
22//!
23//! [`SessionEvent`]: mentra::SessionEvent
24
25mod jsonl;
26mod mapping;
27
28use std::path::PathBuf;
29
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33pub use jsonl::JsonlWriter;
34
35/// Version of the JSONL wire format. Bumped when a change would break a
36/// consumer that reads the current shape.
37pub const EVENT_SCHEMA_VERSION: u32 = 1;
38
39/// One line of the stream: a sequence number and the event itself, flattened
40/// so a line is a single flat JSON object.
41#[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/// Whether a tool call can change anything outside the process.
55#[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/// How a permission request was resolved.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum PermissionOutcome {
67    Allowed,
68    Denied,
69}
70
71/// How far a remembered permission decision reaches.
72#[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/// Severity of an out-of-band notice.
81///
82/// `Info` is the default, and deliberately the quiet one: a reader defaulting
83/// a missing severity must not invent an alarm nobody raised.
84#[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/// What kind of concurrent work a task event describes.
93#[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/// Where a task is in its lifecycle.
102#[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/// How a run ended.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(tag = "status", rename_all = "snake_case")]
114#[non_exhaustive]
115pub enum RunOutcome {
116    /// The turn completed and the assistant produced a final message.
117    Ok,
118    /// The run failed. `message` is the operator-facing reason: the failure's
119    /// own words together with whatever its cause chain adds that those words
120    /// did not already say (see `chain_message` in `run/prepared.rs`) — so it
121    /// can read as more than the identically-worded [`Event::Error`], which
122    /// mentra builds from the bare message alone and puts on the stream for
123    /// the same failure.
124    Error { message: String },
125}
126
127/// A skill the run can load by name. Bodies stay out of the stream — they are
128/// what `load_skill` is for, and keeping them out is what makes skills cheap.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct SkillSummary {
131    pub name: String,
132    pub description: String,
133}
134
135/// A prompt template a client can offer as a command.
136///
137/// Bodies stay out of the stream for the same reason skill bodies do: the
138/// stream says what is available, not what it contains.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct TemplateSummary {
141    pub name: String,
142    pub description: String,
143    /// What the template says its arguments look like, when it says.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub argument_hint: Option<String>,
146}
147
148/// A context file that was in effect for the run.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct ContextFile {
151    pub path: PathBuf,
152    pub scope: String,
153}
154
155/// Everything that can appear on the stream.
156///
157/// `RunStarted` and `RunFinished` are basis's own bookends; the rest are
158/// normalized from mentra's [`SessionEvent`](mentra::SessionEvent).
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160#[serde(tag = "type", rename_all = "snake_case")]
161#[non_exhaustive]
162pub enum Event {
163    /// Always the first line. Carries the schema version.
164    RunStarted {
165        schema: u32,
166        basis: String,
167        session_id: String,
168        workspace: PathBuf,
169        model: String,
170        provider: String,
171        /// Context files discovered for this run, weakest precedence first.
172        context_files: Vec<ContextFile>,
173        /// Skills directories in effect, most specific first. Omitted rather
174        /// than empty so a stream without skills stays quiet about them.
175        #[serde(default, skip_serializing_if = "Vec::is_empty")]
176        skills_dirs: Vec<PathBuf>,
177        /// The skills those directories produced, after layering — what the
178        /// model can actually load by name.
179        #[serde(default, skip_serializing_if = "Vec::is_empty")]
180        skills: Vec<SkillSummary>,
181        /// Template directories in effect, most specific first.
182        #[serde(default, skip_serializing_if = "Vec::is_empty")]
183        templates_dirs: Vec<PathBuf>,
184        /// The templates those directories produced, after layering — what a
185        /// client can offer as commands.
186        #[serde(default, skip_serializing_if = "Vec::is_empty")]
187        templates: Vec<TemplateSummary>,
188        /// MCP configuration files in effect, weakest precedence first.
189        ///
190        /// Named for the same reason context files are, and more urgently: an
191        /// `.mcp.json` says which programs to spawn and carries the
192        /// credentials to spawn them with, so it is the last thing that should
193        /// take effect without appearing anywhere.
194        #[serde(default, skip_serializing_if = "Vec::is_empty")]
195        mcp_files: Vec<ContextFile>,
196        /// The servers those files produced, after layering. Names only —
197        /// commands, arguments, and environment stay out of the stream, which
198        /// is the same no-echo rule `McpError` follows.
199        #[serde(default, skip_serializing_if = "Vec::is_empty")]
200        mcp_servers: Vec<String>,
201    },
202
203    UserMessage {
204        text: String,
205        /// How many images the turn carried. A turn can be images alone, and
206        /// `text` is then empty — a consumer rendering only `text` would show
207        /// a blank user message. Absent from the line when zero, so a stream
208        /// written before the field existed reads the same as one after.
209        #[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        /// Parsed tool input when it is valid JSON, else the raw string.
228        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        /// Parsed preview when it is valid JSON, else the raw string.
252        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        /// Reasoning counted *inside* `output_tokens` (the Responses wire).
295        #[serde(default)]
296        reasoning_tokens: u64,
297        /// Thinking counted *outside* `output_tokens` (Gemini). Kept apart
298        /// from `reasoning_tokens` for the reason [`RunUsage`](crate::RunUsage)
299        /// gives: a sum would be wrong for one of the two wires.
300        #[serde(default)]
301        thoughts_tokens: u64,
302    },
303    Notice {
304        /// Defaulted on read — leniency for the reader only, the writer
305        /// always states it: journals written before 0.6.0 hold the CLI's
306        /// synthetic notices with no severity at all, and the message is the
307        /// part a person needs.
308        #[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    /// The session returned to an earlier entry; later turns continue from
325    /// there along a different path.
326    Branched {
327        entry_id: String,
328        /// How many entries left the active path. They stay in the transcript
329        /// and remain reachable.
330        abandoned_entries: usize,
331    },
332
333    /// Always the last line.
334    RunFinished {
335        #[serde(flatten)]
336        outcome: RunOutcome,
337        /// The bound that ended the run, when one did — the same fact the
338        /// CLI's exit `3` carries, for a consumer reading the stream instead
339        /// of the exit code. Absent, not null, on an unbounded finish, so a
340        /// schema-1 consumer that never heard of it reads the line unchanged.
341        #[serde(skip_serializing_if = "Option::is_none", default)]
342        stopped_by: Option<crate::run::Bound>,
343        /// What the run reported spending, summed over its rounds — the same
344        /// figure [`RunReport::usage`](crate::RunReport) carries in-process,
345        /// for the consumers that only ever see the stream.
346        ///
347        /// It rides the finish line because a total is only a total once the
348        /// run is over; the per-round [`Event::Usage`] reports are still there
349        /// for anyone metering as it goes. basis ships no price table — that
350        /// is the host's, and prices change — so this is the last basis-side
351        /// fact between a run and a bill.
352        ///
353        /// Absent, not zero, when the producer stated nothing: an old stream
354        /// and a run that cost nothing are different claims, and only one of
355        /// them is worth acting on. Optional and additive, so
356        /// [`EVENT_SCHEMA_VERSION`] does not move for it.
357        #[serde(skip_serializing_if = "Option::is_none", default)]
358        usage: Option<crate::run::RunUsage>,
359    },
360}
361
362impl Event {
363    /// Normalizes a mentra session event, or `None` when basis's stream already
364    /// carries the information some other way.
365    pub fn from_session_event(event: &mentra::SessionEvent) -> Option<Self> {
366        mapping::from_session_event(event)
367    }
368
369    /// The wire tag `#[serde(tag = "type")]` writes for this variant, without
370    /// serializing anything to ask.
371    ///
372    /// Same-crate, exhaustive, no wildcard — deliberately, and always
373    /// compiled: a variant landing on this enum fails this match first, which
374    /// is the tripwire that forces the sibling crates' wildcard arms
375    /// (`basis-acp/src/update.rs`, the CLI renderer's `progress_line`) to be
376    /// revisited before the new variant can be silently eaten.
377    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
404/// `skip_serializing_if` for a count that is only news when it is not zero.
405fn is_zero(count: &usize) -> bool {
406    *count == 0
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    /// [`Event::type_tag`] is the compile-time tripwire behind
414    /// `#[non_exhaustive]` — the same-crate exhaustive match with no
415    /// wildcard, compiled in every build. What a test can add is honesty:
416    /// the hand-written tags must be the tags serde actually writes, or the
417    /// unknown-event lines downstream would name events by names that never
418    /// appear on the wire.
419    #[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    /// The counts a consumer needs to price a run, on the line that says the
562    /// run is over. basis ships no price table, and that argument only holds
563    /// if the numbers arrive: until now the total existed in-process
564    /// (`RunReport::usage`) and nowhere on the wire.
565    ///
566    /// Optional and additive, which is why [`EVENT_SCHEMA_VERSION`] does not
567    /// move: an absent `usage` means a producer that reported none, and a
568    /// schema-1 reader that ignores the key reads the line exactly as before.
569    #[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        // The exit code says `3`; this is the same fact for a consumer reading
621        // the stream instead. It rides `run_finished` rather than a new event
622        // because a bound is a property of how the run ended, and it can
623        // accompany either status — a token budget can end a run that answered.
624        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        // A server list is names, never configuration. The type makes this
668        // true — `mcp_servers` is `Vec<String>` — and the test says why, so a
669        // later change to a richer summary has to argue with it first: an
670        // `.mcp.json` holds the credentials its servers are spawned with, and
671        // this line travels into logs and client error panes.
672        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}