Removing `Event::TaskNote` hours after adding it made every transcript that
had recorded one unreadable. `Transcript::open` parses every line, so `launch`
failed for those sessions and `SessionManager::new` logged
"couldn't relaunch session <id>" and skipped them -- and a skipped session has
no pump and no driver. On the phone that is no status, no history and nothing
sendable, for every live session that had run a background task. One
unfamiliar word took down every conversation it appeared in.
A transcript is append-only and permanent, so the set of kinds one can hold
only ever grows: what this build writes is not what it may have to read. A
line can come from a newer server, or from an older one that wrote a kind
since dropped, and neither may be able to end the file.
`Indexed::parse_at` degrades a line it cannot make sense of to
`Event::Unreadable { kind }` instead of failing the whole read. It keeps the
line's seq -- the cursors, the page bisection and the next-seq counter are all
addressed by it, and dropping the line would hand out a seq the file already
contains -- and carries the word the line called itself, so the phone can say
what is missing rather than that something is. A line with no readable seq is
still an error: that one cannot be placed at all.
`Event::TaskNote` comes back retired rather than deleted: deserializable,
never constructed, dated, with the reason on it. The phone folds it to no row,
which is the point -- an unreadable line correctly draws a placeholder, and
one per background task is the wall the row was removed for in the first
place.
Found while diagnosing a report that live sessions had lost their status and
could not be sent to. 173 server tests pass, including the new one, which
fails on the old code within a second.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
707 lines
31 KiB
Rust
707 lines
31 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;
|
|
|
|
/// 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;
|
|
|
|
/// 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,
|
|
},
|
|
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>,
|
|
},
|
|
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>,
|
|
},
|
|
/// 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>,
|
|
},
|
|
/// 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>,
|
|
},
|
|
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<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 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>;
|
|
|
|
/// 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: both
|
|
/// real dialects queue it for injection at the next tool boundary rather
|
|
/// than the end of the turn.
|
|
pub trait Driver: Send + Sync {
|
|
/// 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>);
|
|
/// 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);
|
|
/// 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 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)
|
|
);
|
|
}
|
|
}
|