//! The common event model and the `Driver` trait -- the one abstraction //! everything hangs off (see PLAN.md). //! //! A driver translates its child process's JSONL dialect into [`Event`]s //! and accepts the small inbound vocabulary below. The transcript, the SSE //! stream, and the phone UI work purely in this model; nothing downstream //! of a driver may branch on the session kind. use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; /// 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; /// Stores image bytes where the files route serves them and returns their transcript reference. /// Both CLI dialects produce images in different envelopes; the durable file and naming rule are /// part of the common event model and must not vary with that envelope. pub(in crate::session) fn store_image( session_dir: &std::path::Path, media_type: &str, bytes: &[u8], ) -> Option { let extension = crate::media::extension_for(media_type).unwrap_or("png"); let name = format!("{}.{extension}", super::random_hex()); let dir = session_dir.join("files"); if let Err(err) = wg_app_link::private::create_dir(&dir) .map_err(std::io::Error::other) .and_then(|()| std::fs::write(dir.join(&name), bytes)) { tracing::error!("couldn't save produced image: {err}"); return None; } Some(name) } /// Prefixed to a message that was typed while a turn was already running. /// /// A steer goes to the CLI the moment it arrives, but *when the model reads /// it* is not ours to decide: it lands at the next model call if there is /// one, and a turn that ends first delivers it as the opening line of the /// next turn instead -- Claude's from the fifo, Codex's requeued after /// `activeTurnNotSteerable`. Read there it looks like a reply to the answer /// just given, so the model acts as if the person had seen that answer, which /// is exactly what they had not. Nothing else distinguishes the two cases by /// the time the model sees them, so the note is the only thing that can carry /// the fact. const STEERING_NOTE: &str = "[Sent while you were still working, so it was written without \ having seen the rest of that turn. Treat it as steering the work in progress, not as a \ reply to anything you said after it was sent.]"; /// The text a CLI receives for one message: the note above when this is a /// steer, the typed words, and the paths of any attachment the model has to /// read from disk rather than being handed inline. /// /// The paths come after the words because a model reading a list of files /// before the request treats the list as the request. pub(in crate::session) fn message_body( text: &str, files: &[std::path::PathBuf], steering: bool, ) -> String { let mut body = String::new(); if steering { body.push_str(STEERING_NOTE); } for part in std::iter::once(text.to_string()).chain( files .iter() .map(|path| format!("Attached file: {}", path.display())), ) { if part.is_empty() { continue; } if !body.is_empty() { body.push_str("\n\n"); } body.push_str(&part); } body } /// 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, }, /// The authoritative text of the assistant message whose deltas immediately /// precede this event. /// /// Some providers stream a provisional rendering and revise it before the /// item completes. This stays append-only like every other transcript /// correction: readers replace the open message rather than editing an old /// line, and replay therefore reaches the same text as the live stream. AssistantTextFinal { text: 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, }, /// **Retired on 2026-09-06, hours after it was added.** Kept only so the /// transcripts written while it existed still read: a session that ran a /// background task in that window has these lines for ever, and a /// transcript is append-only, so there is no pass that could remove them. /// /// Never constructed. It reported a background task finishing, and drawing /// a row per one turned out to be a screen of notices about work the /// reader was not asking after -- see PLAN.md's "Two turns must never be /// drawn as one". The phone folds it to no row at all, which is what makes /// keeping it cheap. /// /// Deleting the variant instead is what broke every live session, and /// [`Event::Unreadable`] is the reason that cannot happen again. This is /// still here rather than left to that: an unreadable line draws a /// placeholder, correctly, and one per background task is the same wall /// the row was removed for. TaskNote { about: String, #[serde(default, skip_serializing_if = "Option::is_none")] title: Option, status: String, #[serde(default, skip_serializing_if = "Option::is_none")] summary: Option, }, /// A line in a transcript that this build cannot read: a kind a newer /// server wrote, a kind an older one wrote that has since been dropped, or /// a line whose contents do not fit the kind it names. /// /// **Only ever made when reading, never sent by a driver**, and it is the /// reason a transcript can outlive a change to this enum. See /// `Indexed::parse_at` for the incident: removing a variant after /// transcripts had recorded it made every read of those files fail, so /// every session in them lost its status, its history and its ability to /// be sent to. /// /// It carries the word the line called itself so a reader is told what /// they are missing rather than that something is missing. `kind` is not /// an enum for the obvious reason: the whole point of this variant is the /// words that are not in one. Unreadable { kind: String, }, /// 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, }, /// How many background tasks the provider says are alive now. /// /// State rather than a transcript row: the phone draws it beside the /// session status. A distinct event keeps the count current while a /// session is open; `GET /sessions` supplies the opening snapshot. BackgroundTasks { count: usize, }, 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, }, /// How much context this session's model has to hold a conversation in. /// /// The denominator the phone draws [`Event::UsageDelta`]'s `context` /// against, and its own event rather than a field on that one because it /// is not a per-turn measurement: it is fixed when the process starts and /// changes only when a different one is started, which is what a model /// change does. Reported the moment it is known, so the figure and what it /// is out of arrive together rather than the first turn drawing a /// numerator with no denominator. /// /// **Only ever sent by a driver that actually knows.** A window nobody has /// measured is not an unlimited one: llama.cpp answers it exactly, because /// the number is a flag the server was started with and `/props` reads it /// back, while a coding CLI's context is the vendor's business and /// nothing in either control protocol states it. Those send nothing, the /// session has no limit, and the phone draws the figure on its own -- see /// `SessionSummary::context_limit`. ContextWindow { tokens: u64, }, /// 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, }, /// The provider refused the turn because its login can no longer be used. /// /// Recognised by the driver for the same reason [`Event::LimitReached`] is: /// only that layer knows the provider's dialect, and the phone needs a /// state it can act on without matching error prose. AuthenticationRequired { message: String, }, Error { message: String, }, } /// The common presentation of a file change, whichever driver produced it. pub(super) fn patch_start(id: String, diff: String) -> Event { Event::ToolStart { id, tool: "Patch".to_string(), input: serde_json::json!({"diff": diff}), } } pub(super) fn prefixed_lines(prefix: char, text: &str) -> String { text.split_inclusive('\n') .map(|line| { if line.ends_with('\n') { format!("{prefix}{line}") } else { format!("{prefix}{line}\n") } }) .collect() } /// 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 window after `event`, given what it was before. /// /// Beside [`context_after`] because it has the same three readers and the same /// hazard: a figure that outlives what made it true. A model change replaces /// the process, so the window it reports replaces the old one -- and until the /// new one says, there is no answer rather than the previous model's. pub fn context_limit_after(current: Option, event: &Event) -> Option { match event { Event::ContextWindow { tokens } => Some(*tokens), // The window belongs to the process, and a stopped one has none. Left // standing, a restarted session on a different model would draw its // occupancy against the previous model's window. Event::Status { state: SessionStatus::Exited, } => None, _ => current, } } /// 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, } } /// Something a session can be asked to do to itself. /// /// A closed set rather than a string, because the two that are not /// dialect-specific have to reach every provider: compaction is a capability /// an llama session may one day have, and a name is this server's own. `Raw` /// is the escape for a dialect's own commands, which only the thing running /// the session can interpret. #[derive(Debug, Clone, PartialEq)] pub enum SessionCommand { Compact, Clear, SetTitle(String), Raw(String), } impl SessionCommand { /// What a person would have typed to ask for this, which is what a phone /// shows while it waits. pub fn label(&self) -> String { match self { Self::Compact => "/compact".to_string(), Self::Clear => "/clear".to_string(), Self::SetTitle(title) => format!("/rename {title}"), Self::Raw(text) => text.clone(), } } /// Runs it. Called only at a boundary -- see [`Event::CommandQueued`]. pub fn apply(&self, driver: &dyn Driver) { match self { Self::Compact => driver.compact(), Self::Clear => driver.clear(), Self::SetTitle(title) => driver.set_title(title), Self::Raw(text) => driver.run_command(text), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SessionStatus { Idle, Running, AwaitingInput, Compacting, /// The session's process is up but cannot be spoken to yet. /// /// Its own state because the two it would otherwise borrow are both /// wrong in ways somebody notices. `Running` means the session is /// answering, so a model taking a minute to load looks like a model /// thinking for a minute -- and there is no way to tell from the screen /// that the first message will be refused. `Idle` invites that message /// and then loses it. /// /// It exists for `llama-server`, which reads a multi-gigabyte file off /// disk before it answers anything, and it is general because the /// condition is: a process that is started and not yet ready is a state /// any driver may have to report. Nothing is queued *because* of this /// state -- a driver that reports it is responsible for holding what it /// is sent until it can deliver it -- but this is what says so on screen. Loading, /// The session's own turn is over, but work it started is still going: /// a backgrounded subagent, or a command left running. /// /// Its own state rather than `Idle` because the two differ in kind and /// only one of them is an invitation. `Idle` means the session is /// waiting for a person; this means it is waiting for itself, and a /// notification saying the work had finished would have been wrong. It /// is also not `Running`: nothing is being written to the transcript, /// the reply that ended the turn is finished, and a spinner on a session /// that will not speak again until a task reports back is a promise /// nobody can keep. Waiting, 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, } /// What became of a request to take a queued message back. /// /// Three states rather than a bool because the two failures are not the same /// fact. A driver that writes into its session the moment a message arrives /// -- which is what `ClaudeDriver` does, so a steer reaches the model at the /// next tool boundary -- can never take one back, and a phone told only "no" /// would have to guess whether it asked too late or asked about nothing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Unqueued { /// Out of the queue; the session will never read it. Dropped, /// Already handed to the session, so there is nothing left to take back. AlreadySent, /// Nothing is waiting under that id. Unknown, } /// Where a driver reports events. Unbounded because producers are child /// processes a slow phone must never be able to stall; the transcript file /// is the backpressure-free buffer of record. pub type EventSink = mpsc::UnboundedSender; /// The inbound half of a session. Deliberately small; see PLAN.md for the /// per-driver mapping of each method onto its dialect. /// /// `send_user_message` during a run is the point of the whole app: a dialect /// with live input injects it at the next tool boundary, while a turn-at-a-time /// dialect queues it for the next child process. pub trait Driver: Send + Sync { /// The provider's latest measured number of live background tasks. /// `None` means it has not reported one, not that the count is zero. fn background_tasks(&self) -> Option { None } /// Takes a message, now or once the session is free for it. /// /// Every driver owes exactly one `MessageTaken` per message, at the moment /// it actually starts reading it: that event is what puts the message in /// the transcript, so a driver that never sends it drops the message from /// the conversation entirely. fn send_user_message(&self, text: String, attachments: Vec); /// Takes back a message that is still waiting, named by the id its /// [`Event::MessageQueued`] carried. /// /// Answering is the whole of the contract: a driver that drops the message /// owes an [`Event::MessageDropped`], and one that cannot must say which /// of the two reasons it is -- "the session has already been told" is /// worth knowing, and "there is nothing under that id" means the bubble on /// screen is stale. The default is the honest answer for a driver with no /// queue at all. fn unqueue(&self, _id: &str) -> Unqueued { Unqueued::Unknown } /// Answers one question with everything that was chosen, in the order it /// was offered. A driver whose dialect takes a single value joins them /// where it writes it. fn answer_question(&self, id: &str, answers: &[String]); /// Stop mid-run; the session survives. fn interrupt(&self); fn set_model(&self, model: &str); /// How much the session asks about before acting. Live rather than /// spawn-only: the answer changes with what is being done, and a phone is /// the worst place to answer "may I run this?" forty times. fn set_permission_mode(&self, mode: &str); // Both of the above are requests, and neither reports the outcome by // returning. A driver that changes the setting owes an [`Event::Settings`] // once it has -- that event, not the request, is what the manager and the // phone read. One that cannot owes an [`Event::Error`] saying why. /// Tells the process what this conversation is called, when it has /// somewhere to put it. /// /// Unlike the two above, this is not a request that can fail: the rename /// has already happened in this server's config, which is what a phone /// lists. So a driver whose process has no notion of a name does nothing /// and says nothing. Claude Code has one: `--name` at creation and /// `/rename` afterwards, which is what puts the same name in its own /// session picker and in what other agents see. fn set_title(&self, title: &str); /// Takes the session's provider settings, whole. /// /// The whole map because it is a form's contents -- an absent key means /// "unset", not "unchanged". A driver applies what it can apply now and /// says so about the rest: the map is also on disk by the time this is /// called, so a setting that only takes effect at the next start is not /// lost, it is waiting. The default is right for a driver with no settings /// of its own, which is every one but llama.cpp -- see /// [`crate::config::DriverKind::params`]. fn set_params(&self, _params: &std::collections::BTreeMap) {} /// Runs a command this session's own dialect understands, verbatim -- /// `/context`, `/usage`, anything a CLI adds next month. A driver with no /// such vocabulary says so with an [`Event::Error`] rather than sending it /// as a message, which would put a line meant for the session in front of /// the model. /// /// Called only when the session is between turns; the waiting is done /// above, once, for every driver. fn run_command(&self, text: &str); /// llama: not built, and refused; claude: `/compact`. fn compact(&self); /// Drops the conversation so far without ending the session. /// /// The cheap half of managing a long session, and why it is a driver /// operation rather than a manager one: compaction *reads* the whole /// conversation in order to summarise it, so on a large context it is /// itself one of the most expensive requests the session will make -- /// measured at 1.7 million tokens for one automatic compaction on /// 2026-08-29. Clearing costs nothing, because nothing is sent. /// /// Every implementation emits [`Event::Cleared`] so the transcript carries /// the divider whatever the dialect did behind it. fn clear(&self); /// Stop attending to the process but leave it running, because this /// server is going away and means to adopt it again. /// /// Deliberately not a shutdown: a backend restart must not end a turn that /// is in flight, so a session's process outlives the server that started /// it and is found again through `session::process`. /// /// Its counterpart is [`Driver::stop`]. Every driver owes exactly one of /// the two on the way out, and which one is the difference between "back /// shortly" and "this conversation is over". /// Whether a line written *now* would start a turn of its own, rather than /// landing inside one already in flight. /// /// Asked of the driver because the driver is the only thing that knows: it /// updates this the instant it writes rather than when output returns. The /// manager's `SessionStatus` is built from what has been *recorded*, so /// between writing a line and the CLI's first output it still reads idle, /// and a second line sent in that gap lands inside the turn the first one /// started. For a command that is the difference between being executed /// and being read to the model as text, which is silent both ways. /// /// Defaults to true for a driver with no turn of its own to be inside. fn between_turns(&self) -> bool { true } fn detach(&self); /// End the process for good, because it must not survive this. The path /// out for everything [`Driver::detach`] preserves. /// /// Two callers, differing only in what is being ended: a session being /// deleted, whose conversation goes with it, and a throwaway session at a /// server's exit, whose transcript stays and whose process does not. fn stop(&self); } #[cfg(test)] mod tests { use super::*; /// A steer says so to the model and nowhere else: the note goes only into /// what the CLI is handed, while the transcript keeps what was typed. #[test] fn only_a_steer_carries_the_note() { let files = [std::path::PathBuf::from("/tmp/x/trace.txt")]; let plain = message_body("do the thing", &files, false); assert_eq!(plain, "do the thing\n\nAttached file: /tmp/x/trace.txt"); let steer = message_body("do the thing", &files, true); assert_eq!(steer, format!("{STEERING_NOTE}\n\n{plain}")); // Attachments with nothing typed still need the note, and must not // arrive with a blank line where the words would have been. assert_eq!( message_body("", &files, true), format!("{STEERING_NOTE}\n\nAttached file: /tmp/x/trace.txt") ); } /// A tripwire for the wire format, not for serde. The app reads these /// names, and getting one wrong does not fail loudly: a field the app /// cannot find reads as a field the server chose not to send, which /// several of them are allowed to be. #[test] fn multi_word_fields_go_out_in_camel_case() { let json = serde_json::to_value(Event::Compacted { pre_tokens: Some(28719), post_tokens: Some(1125), trigger: Some("manual".to_string()), }) .expect("serialize"); assert_eq!( json, serde_json::json!({ "type": "compacted", "preTokens": 28719, "postTokens": 1125, "trigger": "manual", }) ); } /// The two events that take the context *down* are the point of the fold: /// a figure measured before a compaction or a clear 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. #[test] fn a_compaction_and_a_clear_move_the_context_a_turn_cannot() { let after = |current, event| context_after(current, &event); assert_eq!( after( Some(500), Event::UsageDelta { tokens: 12, context: Some(30_100), } ), Some(30_100) ); assert_eq!( after( Some(128_402), Event::Compacted { pre_tokens: Some(128_402), post_tokens: Some(9_617), trigger: Some("auto".to_string()), } ), Some(9_617) ); assert_eq!(after(Some(9_617), Event::Cleared), None); // A compaction that did not say how much it recovered leaves the // context unknown rather than stale: it definitely moved. assert_eq!( after( Some(128_402), Event::Compacted { pre_tokens: None, post_tokens: None, trigger: None, } ), None ); // A turn the dialect reported no context for is stale by a turn, // which every context figure is, rather than unknown. assert_eq!( after( Some(30_100), Event::UsageDelta { tokens: 12, context: None, } ), Some(30_100) ); // Everything else leaves it alone. assert_eq!( after( Some(30_100), Event::Status { state: SessionStatus::Idle, } ), Some(30_100) ); } }