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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum NoticeSeverity {
84    Info,
85    Warning,
86}
87
88/// What kind of concurrent work a task event describes.
89#[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/// Where a task is in its lifecycle.
98#[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/// How a run ended.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(tag = "status", rename_all = "snake_case")]
110pub enum RunOutcome {
111    /// The turn completed and the assistant produced a final message.
112    Ok,
113    /// The run failed. `message` is the operator-facing reason: the failure's
114    /// own words together with whatever its cause chain adds that those words
115    /// did not already say (see `chain_message` in `run/prepared.rs`) — so it
116    /// can read as more than the identically-worded [`Event::Error`], which
117    /// mentra builds from the bare message alone and puts on the stream for
118    /// the same failure.
119    Error { message: String },
120}
121
122/// A skill the run can load by name. Bodies stay out of the stream — they are
123/// what `load_skill` is for, and keeping them out is what makes skills cheap.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct SkillSummary {
126    pub name: String,
127    pub description: String,
128}
129
130/// A prompt template a client can offer as a command.
131///
132/// Bodies stay out of the stream for the same reason skill bodies do: the
133/// stream says what is available, not what it contains.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct TemplateSummary {
136    pub name: String,
137    pub description: String,
138    /// What the template says its arguments look like, when it says.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub argument_hint: Option<String>,
141}
142
143/// A context file that was in effect for the run.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct ContextFile {
146    pub path: PathBuf,
147    pub scope: String,
148}
149
150/// Everything that can appear on the stream.
151///
152/// `RunStarted` and `RunFinished` are basis's own bookends; the rest are
153/// normalized from mentra's [`SessionEvent`](mentra::SessionEvent).
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155#[serde(tag = "type", rename_all = "snake_case")]
156pub enum Event {
157    /// Always the first line. Carries the schema version.
158    RunStarted {
159        schema: u32,
160        basis: String,
161        session_id: String,
162        workspace: PathBuf,
163        model: String,
164        provider: String,
165        /// Context files discovered for this run, weakest precedence first.
166        context_files: Vec<ContextFile>,
167        /// Skills directories in effect, most specific first. Omitted rather
168        /// than empty so a stream without skills stays quiet about them.
169        #[serde(default, skip_serializing_if = "Vec::is_empty")]
170        skills_dirs: Vec<PathBuf>,
171        /// The skills those directories produced, after layering — what the
172        /// model can actually load by name.
173        #[serde(default, skip_serializing_if = "Vec::is_empty")]
174        skills: Vec<SkillSummary>,
175        /// Template directories in effect, most specific first.
176        #[serde(default, skip_serializing_if = "Vec::is_empty")]
177        templates_dirs: Vec<PathBuf>,
178        /// The templates those directories produced, after layering — what a
179        /// client can offer as commands.
180        #[serde(default, skip_serializing_if = "Vec::is_empty")]
181        templates: Vec<TemplateSummary>,
182        /// MCP configuration files in effect, weakest precedence first.
183        ///
184        /// Named for the same reason context files are, and more urgently: an
185        /// `.mcp.json` says which programs to spawn and carries the
186        /// credentials to spawn them with, so it is the last thing that should
187        /// take effect without appearing anywhere.
188        #[serde(default, skip_serializing_if = "Vec::is_empty")]
189        mcp_files: Vec<ContextFile>,
190        /// The servers those files produced, after layering. Names only —
191        /// commands, arguments, and environment stay out of the stream, which
192        /// is the same no-echo rule `McpError` follows.
193        #[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        /// Parsed tool input when it is valid JSON, else the raw string.
216        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        /// Parsed preview when it is valid JSON, else the raw string.
240        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    /// The session returned to an earlier entry; later turns continue from
300    /// there along a different path.
301    Branched {
302        entry_id: String,
303        /// How many entries left the active path. They stay in the transcript
304        /// and remain reachable.
305        abandoned_entries: usize,
306    },
307
308    /// Always the last line.
309    RunFinished {
310        #[serde(flatten)]
311        outcome: RunOutcome,
312        /// The bound that ended the run, when one did — the same fact the
313        /// CLI's exit `3` carries, for a consumer reading the stream instead
314        /// of the exit code. Absent, not null, on an unbounded finish, so a
315        /// schema-1 consumer that never heard of it reads the line unchanged.
316        #[serde(skip_serializing_if = "Option::is_none", default)]
317        stopped_by: Option<crate::run::Bound>,
318    },
319}
320
321impl Event {
322    /// Normalizes a mentra session event, or `None` when basis's stream already
323    /// carries the information some other way.
324    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        // The exit code says `3`; this is the same fact for a consumer reading
455        // the stream instead. It rides `run_finished` rather than a new event
456        // because a bound is a property of how the run ended, and it can
457        // accompany either status — a token budget can end a run that answered.
458        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        // A server list is names, never configuration. The type makes this
501        // true — `mcp_servers` is `Vec<String>` — and the test says why, so a
502        // later change to a richer summary has to argue with it first: an
503        // `.mcp.json` holds the credentials its servers are spawned with, and
504        // this line travels into logs and client error panes.
505        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}