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>
291 lines
12 KiB
Rust
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)
|
|
);
|
|
}
|
|
}
|