Files
ai-app/server/src/session/driver.rs
T
irisandClaude Sonnet bc3db183e3 Extract the event model into its own crate, shared with client-core
RUST.md's recommendation item 1 starts here: Event, QuestionOption,
SessionStatus, ImageRef, AttachmentRef, SeqEvent, context_tokens and
context_after move to a new event-model crate so a future Rust client
shares one definition with server/ instead of Events.kt's hand-kept
mirror. session/driver.rs and session/transcript.rs re-export
everything they used to define, so nothing downstream of either
module changed.

cargo test (127 passed), clippy --all-targets and fmt clean in both
server/ and event-model/.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-04 22:40:15 -04:00

291 lines
12 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.
//!
//! The event model itself -- [`Event`], [`QuestionOption`], [`SessionStatus`],
//! [`AttachmentRef`], `ImageRef`, [`context_tokens`] and [`context_after`] --
//! moved to the `event-model` crate on 2026-09-04, so `client-core` can share
//! one definition with this server instead of a hand-kept Kotlin mirror.
//! Re-exported here so nothing downstream of this module had to change; what
//! stayed behind is the *driver* abstraction, which is how this server runs
//! a session rather than part of what a client reads off the wire.
pub use event_model::{
AttachmentRef, Event, QuestionOption, SessionStatus, context_after, context_tokens,
};
use tokio::sync::mpsc;
/// 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),
}
}
}
/// 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)
);
}
}