//! The common event model: what a driver's process turns into before it //! touches the transcript or the phone (see `PLAN.md`'s "The common event //! model"). Extracted from `server/src/session/driver.rs` and //! `session/transcript.rs` on 2026-09-04 so `client-core` shares this //! definition instead of hand-mirroring it, which is what //! `app/.../Events.kt` used to do. `server/`'s `session::driver` module //! re-exports everything here, so nothing downstream of it had to change. //! //! What stayed behind in `server/`: the `Driver` trait, `SessionCommand`, //! `Unqueued` and `EventSink`. Those are how *this* server runs a session, //! not part of what a client reads off the wire. use serde::{Deserialize, Serialize}; /// The name a session's image is stored and served under -- minted for an /// upload or for one a tool produced, and fetched back from /// `/sessions/{id}/files/{ref}`. One id both directions, so the transcript /// renders them identically. pub type ImageRef = String; /// The name an upload is stored and served under: an image is /// `.` and is an [`ImageRef`] like any other; any other file /// keeps its own name after the hex, `-`, because the name is what /// the reader attached and what the session is told. Told apart by /// `crate::media::media_type_for`. pub type AttachmentRef = String; /// One choice offered in answer to a [`Event::Question`]. More than a label /// because the reader is deciding rather than confirming: what an option /// means, and what picking it would produce, are what decide it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QuestionOption { pub label: String, /// A sentence about what this option means. #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, /// A block to show as written -- a mockup, a diff, a config file. #[serde(default, skip_serializing_if = "Option::is_none")] pub preview: Option, } impl QuestionOption { pub fn plain(label: impl Into) -> Self { Self { label: label.into(), description: None, preview: None, } } } /// Everything a session can tell the outside world. Every event is appended /// to the transcript with a sequence number, then fanned out to SSE /// subscribers, so reconnecting is just "events after seq N" -- no separate /// history path to drift from the live one. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] // `rename_all` renames the variants; `rename_all_fields` renames what is // inside them. Both are needed and only the first is obvious: every field // here was one lowercase word until `pre_tokens` arrived, so a multi-word // field went out as snake_case, the app looked for camelCase and found // nothing, and the event still rendered -- as the "no counts reported" case, // which is a state it is allowed to be in. #[serde( tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase" )] pub enum Event { /// What the user sent, written into the transcript by the manager (not by /// drivers) so every device renders the conversation from one stream. /// Recorded when the session reads it, which is what `MessageTaken` reports. UserMessage { /// The [`Event::MessageQueued`] this resolves, when it waited. The /// phone has a bubble on screen for the waiting message and needs to /// know *which* one this is, rather than matching on the text and /// clearing the wrong one when the same thing was sent twice. #[serde(default, skip_serializing_if = "Option::is_none")] id: Option, text: String, /// What was attached, by the ref the files route serves. On the /// message rather than beside it: these used to be their own `Image` /// events just before, which left the phone deciding from adjacency /// which message an image belonged to. `images` on disk until /// 2026-09-03, when files joined them; the alias reads the older rows. #[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")] attachments: Vec, }, /// A message accepted from the phone that the session cannot read yet. /// /// Recorded, unlike the message itself, and that difference is the point: /// the message belongs in the transcript where the session read it, but /// something has to say it is waiting, and it has to be the server. The /// phone used to remember its own outgoing messages, so leaving the /// screen showed nothing pending when something was. /// /// Carries no row of its own; resolved by the `UserMessage` bearing the /// same id, as `CommandQueued` is resolved by `CommandSent`. MessageQueued { id: String, text: String, /// Carried for the same reason [`Event::UserMessage`] carries it, /// and it matters more here: a waiting message is on screen for as /// long as the turn runs, so its attachment has nowhere else to be. #[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")] attachments: Vec, }, /// A message taken out of the queue before the session read it. /// /// Recorded for the same reason `MessageQueued` is: the queue is the /// server's, so what is waiting has to be answerable from the transcript /// alone. Without it a phone that reconnects replays the `MessageQueued` /// and puts back a bubble nothing will ever resolve -- the `UserMessage` /// that normally does is exactly what is not coming. /// /// Only ever sent for a message that had not been handed over; see /// [`Unqueued::AlreadySent`]. MessageDropped { id: String, }, /// A driver has taken one of the user's messages and started reading it. /// The manager turns this into the `UserMessage` above, so it never /// reaches a phone itself. /// /// It exists because sending and being read are not the same moment. A /// message sent into a running turn waits, and recording it among things /// already read puts it in the transcript above output that predates it. MessageTaken { /// The `MessageQueued` this answers, or `None` when it never waited. /// Carried through onto the `UserMessage`. id: Option, text: String, #[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")] attachments: Vec, }, /// Streaming assistant text; the phone renders the concatenation as /// markdown. AssistantText { delta: String, }, ToolStart { id: String, tool: String, input: serde_json::Value, }, ToolUpdate { id: String, output: String, }, ToolEnd { id: String, output: String, }, /// An image the session produced or was sent, saved under the session /// dir and referenced by id; the phone fetches it by URL. Image { #[serde(rename = "ref")] image: ImageRef, /// The tool call whose result carried it, when one did. A screenshot /// belongs under the call that took it, not floating beside it -- the /// reader has to pair them by position otherwise, and position is /// exactly what a page boundary breaks. #[serde(default, skip_serializing_if = "Option::is_none")] about: Option, }, /// Anything the session needs a human for: AskUserQuestion, and /// permission requests, are the same shape with different options. Question { id: String, prompt: String, /// A few words naming what the question is about, when the asker /// offered one. `None` for a permission, which is about the call /// above it. header: Option, options: Vec, /// Whether several options may be chosen at once. Here rather than /// left for a phone to work out from the dialect underneath: how many /// answers a question takes is a fact about the question, and the /// alternative was Claude Code's tool-input schema written out a /// second time in Kotlin, where no other dialect could reach it. #[serde(default, skip_serializing_if = "std::ops::Not::not")] multi_select: bool, /// The tool call this is permission for, when it is one, so a phone /// can draw the ask on the tool's own row rather than as a second /// card repeating its input. `None` for anything not about a tool. #[serde(default, skip_serializing_if = "Option::is_none")] about: Option, }, /// A message another agent sent this session. /// /// Its own kind rather than a `UserMessage`, because it is not something /// the reader said and a transcript that renders it in their voice is /// claiming they did. It also explains what would otherwise be /// inexplicable: a session working on something nobody here asked for. PeerMessage { /// The sending session's own name, which is what the reader /// recognises it by -- the socket path it came from is not. from: String, text: String, /// The seq of the `Status::Running` that opened the turn this message /// started, so a reader can draw it above that turn. /// /// The CLI says nothing about a peer message until the turn's /// `result`, so the event is appended after everything it caused, and /// an append-only transcript cannot go back and insert it. Carrying /// the position instead keeps one order on the wire and one on screen. /// /// Filled in by the pump, the only place that knows a seq, and only /// where a turn was open: `None` for a message replayed by `import`, /// which already has it in the right place. #[serde(default, skip_serializing_if = "Option::is_none")] turn_start: Option, }, /// The manager's record of a question being answered, so a rendered /// question card resolves on every device rather than only the one that /// answered. /// /// A list because a question can take several answers, and one that took /// one is the list of length one rather than a different shape. Answered { id: String, answers: Vec, }, Status { state: SessionStatus, }, /// What the session is set to, as the session itself reports it. /// /// Asking for a change and having one are different things, and only this /// is a measurement: a model name the dialect does not know, a mode it /// refuses, or a driver whose model is fixed at startup all leave a /// request that was sent and nothing that changed. Reporting from the /// request put the answer on the phone before the question was answered. /// /// Either field alone, because the two are confirmed separately. Settings { #[serde(default, skip_serializing_if = "Option::is_none")] model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] permission_mode: Option, }, /// Per-turn token counts, where the dialect reports them. UsageDelta { /// What this turn cost: the tokens it was charged for. tokens: u64, /// What the model was holding when the turn ended -- see /// [`context_tokens`]. /// /// Carried rather than summed by whoever is reading, because it is /// not a sum: context goes *down* at a compaction and a clear, so /// adding turns up would report a figure the session stopped being /// true of long ago. /// /// `None` where the dialect did not say, which every reader has to be /// able to draw. #[serde(default, skip_serializing_if = "Option::is_none")] context: Option, }, /// A compaction that finished, and how much context it recovered. /// /// The counts are the point, and a spinner is not. They are optional /// because the record has shipped without them, and "the compaction /// happened, we don't know by how much" is a state this has to be able to /// say -- a plausible number would be indistinguishable from a counted one. Compacted { #[serde(default, skip_serializing_if = "Option::is_none")] pre_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] post_tokens: Option, /// What asked for it, in the dialect's own word -- `auto` when the /// session compacted on its own. Carried rather than reduced to a bool /// so an unrecognised trigger stays unrecognised: an automatic /// compaction is the one worth naming, because it explains a wait /// nobody asked for. #[serde(default, skip_serializing_if = "Option::is_none")] trigger: Option, }, /// A command the session was asked to run on itself, held because it /// cannot run yet. These are not messages: `/compact` and `/rename` are /// instructions about the session, and a session mid-turn reads a line /// written to it as something the model should see. So they wait, and /// this is what a phone draws while they do. CommandQueued { id: String, text: String, }, /// The same command, now handed to the session. Its [`CommandQueued`] /// stops being pending when this arrives, matched by `id`; a command /// that ran immediately has only this. CommandSent { id: String, text: String, }, /// The conversation was cleared: everything above this is still in the /// record but is no longer in the session's context. /// /// Nothing is deleted. A transcript is the thing a person scrolls back /// through, so this is a divider, not a truncation. /// /// **Load-bearing, not decorative.** For any driver that rebuilds its /// conversation from the transcript, this marker decides what the model /// is given -- dropping it, or treating it as something only the phone /// draws, silently puts a cleared conversation back in front of the model /// at full cost. Today `llama::conversation` is the only fold that reads /// it, which is why this is written down rather than left to be inferred /// from a second example that does not exist. Cleared, /// The account behind this session has no quota left, so the turn stopped /// without finishing. /// /// Its own event rather than an [`Event::Error`] carrying the dialect's /// sentence, because two things act on it that cannot read English: the /// transcript draws it as a state the session is in rather than as a /// failure of something it did, and `crate::resume` schedules the message /// that picks the work back up. Recognising it belongs to the driver, which /// is the only layer that knows its dialect's wording -- above here nothing /// matches on strings. /// /// `resets_at` is epoch seconds, and `None` is a real state: the dialect /// said the limit was hit without saying when it lifts. Nothing here /// invents one -- what the wait is actually decided against is the usage /// endpoint, and this is the hint that starts the waiting. LimitReached { #[serde(default, skip_serializing_if = "Option::is_none")] resets_at: Option, }, Error { message: String, }, } /// How much the model was holding, from the three figures a turn reports: /// the input side only, prompt plus both cache figures. A cached token is /// cheaper but it is still one the model was given; output is what the turn /// produced rather than what continuing has to carry. /// /// One function so the definition cannot drift, because it is extracted two /// quite different ways -- the live translators have the usage object parsed, /// and `import::context_tokens` scans it out of a raw line without parsing. pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 { input + cache_creation + cache_read } /// The context after `event`, given what it was before. /// /// The whole rule in one place, because three readers need the same answer: /// the pump keeping a live session's figure, the transcript seeding it at /// startup, and the phone folding the same events into what it draws. /// /// The two that *lower* it are the point. A clear takes the conversation away /// and a compaction replaces it with a summary, so a figure measured before /// either stopped being true at that moment -- and carrying it forward is how /// a session that had just been cleared went on reporting the context it no /// longer had. /// /// `None` is "we don't know", which each of them can reach. pub fn context_after(current: Option, event: &Event) -> Option { match event { // `or`, so a turn the dialect reported no usage for leaves the last // measurement standing: stale by a turn, which every context figure // is, rather than wrong. Event::UsageDelta { context, .. } => context.or(current), Event::Compacted { post_tokens, .. } => *post_tokens, Event::Cleared => None, _ => current, } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SessionStatus { Idle, Running, AwaitingInput, Compacting, Exited, /// There is a process recorded for this session and the machine will not /// say whether it is still running. /// /// Its own state rather than the nearest of the others, because both /// neighbours are lies with consequences: `Exited` invites starting a /// second process against a conversation that may already have one, and /// `Idle` claims a session is waiting for you when nobody has checked. Unknown, } /// One transcript line: an [`Event`] plus its position and time. The event /// is flattened so the wire shape stays one flat object. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SeqEvent { pub seq: u64, /// Epoch seconds. pub ts: f64, #[serde(flatten)] pub event: Event, }