Files
ai-app/server/src/session/driver.rs
T
iris-ai cedb18e8c1 Draw a background task as what it ran, and go there on a tap
A card in the session's panel said "background command" under every
description -- and for Codex, which names a terminal by a process id and
gives no description at all, that phrase was the whole of every card.

Both halves of the answer are in the transcript rather than in what the
provider says: a driver now reports which tool call its task belongs to
(Claude's `task_started` carries the `tool_use_id`, Codex's terminal list
the `itemId`), and `LiveSession::background_tasks` resolves those ids
against the transcript into a sequence number and, where the provider said
nothing, the command the call was made with. So the card draws the command,
and the kind shrinks to a mark beside it whose name is what a screen reader
is given.

Tapping one goes to that call in the transcript, opened, which is where a
backgrounded command's output already lands -- rather than drawing a second
copy of it beside the panel. The journey is the one a reopened session
already makes to put a reader back where they stopped, now one function
(`travelTo`). It has to release the held backlog first: events arriving
while the reader is away from the newest end are held rather than applied,
so a task started since they scrolled back was in no row at all and the tap
looked like it had done nothing.

Verified against the sandbox on the emulator: the panel draws
`sleep 120 && echo done` for an echo session's `/background`, and tapping
it lands on that Bash card with its output showing.
2026-09-20 18:46:22 -04:00

1088 lines
49 KiB
Rust

//! 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<ImageRef> {
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
/// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
/// keeps its own name after the hex, `<hex>-<name>`, 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;
/// Whether a session can be sent an image.
///
/// Asked before one is attached rather than when it is sent, because that is
/// the only moment at which the answer is worth anything: a photo refused
/// after it has been picked, shrunk and uploaded over the tunnel is a refusal
/// that cost everything sending it would have.
///
/// Three states and not a boolean, for the reason
/// [`crate::session::llama`] has to have them: a local model's answer is the
/// loaded server's to give, so a session whose model is still coming off disk
/// -- or has never been started -- genuinely does not know yet. "We could not
/// find out" drawn as "no" is a control that is never offered on a model that
/// reads images perfectly well.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Images {
/// The model reads them, so attaching one is offered.
Accepted,
/// It does not, and one sent anyway is refused where it arrives.
Refused,
/// Nobody has been able to ask. Offered, because the alternative is
/// withholding the control on every session that has not started yet.
Unknown,
}
/// Where an uploaded attachment sits in a session's directory.
///
/// Refused rather than resolved when the id is not one this server would have
/// written: the id reaches here from a phone and from transcript lines, and a
/// `..` in one would name a file outside the session. Every driver that opens
/// an attachment goes through this, so there is one answer to what an id may
/// hold.
pub(in crate::session) fn attachment_path(
session_dir: &std::path::Path,
id: &str,
) -> anyhow::Result<std::path::PathBuf> {
if id.contains("..")
|| !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
{
anyhow::bail!("invalid attachment id");
}
Ok(session_dir.join("attachments").join(id))
}
/// 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<String>,
/// A block to show as written -- a mockup, a diff, a config file.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
}
impl QuestionOption {
pub fn plain(label: impl Into<String>) -> 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<String>,
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<AttachmentRef>,
},
/// 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<AttachmentRef>,
},
/// 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<String>,
text: String,
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
attachments: Vec<AttachmentRef>,
},
/// 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,
},
/// The model's working, streamed the same way its reply is: the
/// reasoning it produced before -- or between -- the things it said.
///
/// Its own kind rather than [`Event::AssistantText`], because it is not
/// what the session said. The phone draws it as a card of its own, shut,
/// and no driver folds it back into the next prompt: a provider that
/// wants its own reasoning back sends it back itself.
///
/// Only a provider that actually streams its working sends this.
/// llama.cpp does, as `reasoning_content`; nothing is inferred for one
/// that does not, since a card that appeared whenever a turn was slow
/// would be a guess wearing a measurement's clothes.
Thinking {
delta: String,
},
/// The thinking immediately above this finished, having taken `ms`.
///
/// Measured by the driver rather than worked out by a reader from two
/// event timestamps. A reader only knows when an event *arrived*, so the
/// last delta of a block followed by a slow tool call is indistinguishable
/// from thinking that went on that long -- and a transcript replayed on a
/// phone has to reach the same figure the live stream showed.
///
/// A block with no end is one still being thought, which is what the card
/// draws a spinner for.
ThinkingDone {
ms: u64,
},
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<String>,
},
/// 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<String>,
options: Vec<QuestionOption>,
/// 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<String>,
},
/// 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<u64>,
},
/// **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<String>,
status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
summary: Option<String>,
},
/// 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<String>,
},
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
permission_mode: Option<String>,
},
/// Whether a picture can be sent here, as the thing serving the model
/// answered -- see [`Images`].
///
/// Its own event rather than a field on [`Event::Settings`] because it is
/// not something anybody set: it is measured, twice per model, and by a
/// driver rather than asked for by a phone. Emitted by llama.cpp alone,
/// where a model change can take the answer away -- so the phone withdraws
/// the control the moment the model that could read pictures is left,
/// rather than at whatever later point the session row is fetched again.
Images {
images: Images,
},
/// 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<u64>,
/// How fast the reply came out, as the provider measured it.
///
/// `None` wherever nothing measured it, which is most providers: a
/// coding CLI reports what a turn cost and never how long the model
/// took over it, and dividing tokens by the wall time this server
/// waited would count the network, the tool calls and the reader's own
/// permission answers as generation. A figure that is right most of
/// the time is no use here, because nothing on screen could say which
/// times those were.
#[serde(default, skip_serializing_if = "Option::is_none")]
tokens_per_second: Option<f64>,
/// How long the provider spent reading the prompt before it began
/// answering -- see [`SessionStatus::Reading`], which is this while it
/// is happening.
///
/// The provider's own measurement or nothing, for the same reason
/// `tokens_per_second` above is: the wait this server watched also
/// contains the request and whatever else the machine was doing. It is worth reporting because it is the larger half of a
/// turn on a long conversation -- 22 seconds against 3 of generation,
/// measured on a 14,000-token prompt.
#[serde(default, skip_serializing_if = "Option::is_none")]
prefill_ms: Option<u64>,
},
/// 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<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
post_tokens: Option<u64>,
/// 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<String>,
},
/// 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<f64>,
},
/// 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<u64>, event: &Event) -> Option<u64> {
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<u64>, event: &Event) -> Option<u64> {
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 model is reading what it was given, and has not begun answering.
///
/// Its own state for the same reason [`SessionStatus::Loading`] is, one
/// level down: prompt processing is work the machine does before a turn
/// produces anything, and on a long conversation it is the part of the
/// wait somebody is looking at. Reported as `Running` it was
/// indistinguishable from a model thinking, which is what the reader is
/// actually waiting to see.
///
/// It is still a turn in progress -- nothing settles, nothing is invited
/// -- and it is not a measurement of the prompt: it says the request is
/// out and the model has said nothing yet, which for a server serving one
/// slot is what reading the prompt looks like.
Reading,
/// 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<Event>;
/// One piece of work a session has running while it is free to do something
/// else: a backgrounded command, a subagent, whatever else a provider can
/// leave going.
///
/// Runtime state, never written to a transcript: it is what the provider
/// says right now, so a session with no process has nothing to say. What
/// `GET /sessions/{id}/background` answers is this resolved against the
/// transcript -- see [`BackgroundTaskView`](crate::session::BackgroundTaskView);
/// the `BackgroundTasks` event carries only the size, which is what the
/// status row draws.
///
/// [`description`](Self::description) is `None` where the provider names a
/// task by something no reader would recognise -- a process id -- rather
/// than by a sentence worked out here. Where [`call`](Self::call) is known
/// the transcript answers it instead, from the call's own arguments; see
/// [`LiveSession::background_tasks`](crate::session::LiveSession::background_tasks).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackgroundTask {
/// The provider's own id for it. Never shown; it is what makes two
/// snapshots comparable, and what keys the list on the phone.
pub id: String,
pub description: Option<String>,
pub kind: BackgroundTaskKind,
/// The id of the tool call that started it, where the provider says
/// which. `None` is a provider that does not, or one whose account of
/// the start was never seen -- an adopted process mid-task.
pub call: Option<String>,
}
/// What kind of thing a [`BackgroundTask`] is, in the terms the app draws.
/// `Other` is deliberately a state of its own rather than a guess: a
/// provider word this build has not seen is not a command.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum BackgroundTaskKind {
Agent,
Command,
Workflow,
Other,
}
/// 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 background work the provider says is alive now, in the order it
/// wants it read. `None` means it has not reported, not that there is
/// none -- see [`BackgroundTask`].
fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
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<AttachmentRef>);
/// What this session makes of an image *now*, where that is not simply a
/// property of the provider -- `None` leaves the answer to
/// [`crate::config::DriverKind::images`], which is the whole answer for a
/// CLI that takes them whatever it is talking to.
///
/// Overridden by llama.cpp alone, and it has to be: which model a session
/// is on is changeable while it runs, and whether that model reads images
/// is the loaded server's answer rather than the provider's.
fn images(&self) -> Option<Images> {
None
}
/// 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<String, String>) {}
/// 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),
tokens_per_second: None,
prefill_ms: None,
}
),
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,
tokens_per_second: None,
prefill_ms: None,
}
),
Some(30_100)
);
// Everything else leaves it alone.
assert_eq!(
after(
Some(30_100),
Event::Status {
state: SessionStatus::Idle,
}
),
Some(30_100)
);
}
}