Three readings that were each a part presented as the whole.
**"just now", everywhere, after a restart.** A relaunched session took its
last-activity from the clock, so every session the backend brought back
claimed to have been active that instant. On the phone that is every row
reading "just now" and the list -- which sorts by it -- coming back in an
order that means nothing, with the conversation somebody was in the middle
of buried among sessions untouched for days. It comes from the transcript
now, in the pass `Transcript::open` already makes, which is the same
correction `last_status` got and for the same reason: a server that has just
started has been told nothing, and the file is the only thing it knows. The
test backdates a transcript by a day, so it cannot pass by the test being
fast; it fails on the old code with the clock's answer in the message.
**The token total was the newest page's.** The phone added up the
`UsageDelta`s it had received, and it opens a session on the newest page of
the transcript -- so a long conversation reported its last few turns as the
total, and a page with no turn in it reported nothing at all, since zero is
drawn as blank. That is the reading Bryan saw: no tokens, on sessions that
had certainly spent some.
The count belongs to the server, which is the only side that sees every
turn. `UsageDelta` now carries the running total beside the delta, filled in
by the pump rather than by each driver -- a driver knows what its own turn
cost and nothing else does, so a new one cannot get this wrong by leaving it
out -- and the session row reports it for a screen that has not opened the
stream yet. The phone takes the largest total it has seen instead of
accumulating, which also means paging older history cannot move it, and
leaves the seeded figure alone for transcripts recorded before the field
existed. Seeded by summing deltas at startup for exactly that reason.
**The header said the model twice and the machine backwards.** A session's
subtitle now reads `machine · provider`, in that order and with no "on"
joining them, matching the list and the usage dialog -- the "on" made it a
phrase, which works in one order and stops working the moment the same pair
is shown somewhere else. The model is gone from it: the footer's picker
already shows what the session is set to, and two places showing it meant
two things to keep in step, which disagreed for a moment on every switch
since one follows the request and the other the session's own answer.
Checked on the emulator against a twelve-turn session whose visible page
held the last six: the header reads "this machine · echo", the status row
reads "idle", and the total reads 42 tok, which is what `GET
/sessions/{id}` says rather than what the page adds up to.
511 lines
23 KiB
Rust
511 lines
23 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 -- returned by
|
|
/// `POST /attachments` for an upload, minted by a driver for one a tool
|
|
/// produced, and fetched back from `/sessions/{id}/files/{ref}`. Both
|
|
/// directions use the one id so the transcript renders them identically.
|
|
pub type ImageRef = String;
|
|
|
|
/// One choice offered in answer to a [`Event::Question`].
|
|
///
|
|
/// More than a label because the reader is deciding, not confirming: what
|
|
/// an option means, and what picking it would produce, are the things that
|
|
/// decide it. Both are optional -- a permission's Allow and Deny mean
|
|
/// exactly what they say.
|
|
#[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 {
|
|
/// An option that is only its label, which is most of them.
|
|
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 session's transcript with a sequence number, then fanned
|
|
/// out to SSE subscribers; the phone renders purely from this stream, 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 a single 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 were
|
|
// reported" case, which is a state it is allowed to be in. A wire mismatch
|
|
// that lands on a plausible state is invisible; anything added below with a
|
|
// two-word field would have hit the same thing.
|
|
#[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 full conversation from the
|
|
/// one stream. Recorded when the session reads the message, which is
|
|
/// what `MessageTaken` reports.
|
|
UserMessage {
|
|
/// The [`Event::MessageQueued`] this resolves, when it waited.
|
|
///
|
|
/// A message sent between turns is read at once and never queued,
|
|
/// so this is `None` for most of them. It is the pair to the id on
|
|
/// `MessageQueued` and exists for the same reason `CommandSent`
|
|
/// carries one: 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,
|
|
},
|
|
/// 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 -- see `MessageTaken` -- but something has to say it is
|
|
/// waiting, and it has to be the server that says it: the phone used
|
|
/// to remember its own outgoing messages, so leaving the session
|
|
/// screen or restarting the app showed nothing pending when something
|
|
/// was, which reads as "nothing queued" rather than "I have forgotten".
|
|
///
|
|
/// Carries no row of its own. It is resolved by the `UserMessage`
|
|
/// bearing the same id, exactly as `CommandQueued` is resolved by
|
|
/// `CommandSent`.
|
|
MessageQueued {
|
|
id: String,
|
|
text: 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 for that turn to finish, and
|
|
/// until then the session has not seen it -- so recording it among
|
|
/// things already read puts it in the transcript above output that
|
|
/// predates it, and leaves a phone drawing it as still waiting with
|
|
/// nothing coming to say otherwise.
|
|
MessageTaken {
|
|
/// The `MessageQueued` this answers, or `None` when it never
|
|
/// waited. Carried through onto the `UserMessage`.
|
|
id: Option<String>,
|
|
text: String,
|
|
},
|
|
/// 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. `None` for
|
|
/// an image a person attached to their own message, which belongs
|
|
/// to no call.
|
|
#[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 -- a tag beside the question rather than part of
|
|
/// it. `None` for a permission, which is about the call above it.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
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 the app parsing Claude
|
|
/// Code's tool input to find out -- one dialect's 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.
|
|
///
|
|
/// The CLI's `can_use_tool` request carries the `tool_use_id` of
|
|
/// the call it is asking about, 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 that is not about a tool --
|
|
/// AskUserQuestion, and an echo session's question.
|
|
#[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 that starts working on something nobody
|
|
/// on this phone 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 manager's record of a question being answered, so a rendered
|
|
/// question card resolves on every device, not just the one that
|
|
/// answered it.
|
|
///
|
|
/// 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.
|
|
/// What a dialect makes of that -- Claude Code's answers map holds a
|
|
/// string, so several become one line -- is that dialect's business
|
|
/// and is done where it talks to it.
|
|
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 one 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 instead put the answer on the phone before the
|
|
/// question had been answered, and left it there when the answer was
|
|
/// no.
|
|
///
|
|
/// Either field alone, because the two are confirmed separately and
|
|
/// by different things -- the CLI echoes a mode change, and names the
|
|
/// model it resolved an alias to when a session starts.
|
|
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 {
|
|
tokens: u64,
|
|
/// Every token this session has spent, this turn included.
|
|
///
|
|
/// Filled in by the pump, not by drivers: a driver reports what its
|
|
/// turn cost, and only the pump sees all of them. Carried on the
|
|
/// event rather than left to be added up by whoever is reading,
|
|
/// because a reader has only *part* of the transcript -- a phone
|
|
/// opens a session on the newest page -- so a total it summed
|
|
/// itself would be the newest page's total wearing the whole
|
|
/// conversation's label. Worse when the page has no turn in it at
|
|
/// all: the count then reads zero, and a zero is drawn as nothing.
|
|
///
|
|
/// Zero on entries written before this existed, which is why the
|
|
/// pump seeds its running total by adding up `tokens` at startup
|
|
/// rather than reading the last of these.
|
|
#[serde(default)]
|
|
total: u64,
|
|
},
|
|
/// A compaction that finished, and how much context it recovered.
|
|
///
|
|
/// The counts are the point, and a spinner is not: what a reader wants
|
|
/// afterwards is that the session went from a million tokens to ten
|
|
/// thousand, which is measured rather than estimated. 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 -- filling in a plausible number would make
|
|
/// it indistinguishable from one that was counted.
|
|
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, and defaulting the unknown
|
|
/// case to "you asked for this" would explain it away.
|
|
#[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
|
|
/// to the session about itself, and a session in the middle of a turn
|
|
/// reads a line written to it as something the model should see. So
|
|
/// they wait for the turn to end, and this is what a phone draws
|
|
/// while they do -- otherwise pressing Compact during a long turn
|
|
/// does nothing visible for minutes and looks like it was missed.
|
|
CommandQueued {
|
|
id: String,
|
|
/// What to show for it: the command as a person would type it.
|
|
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, and a session that dropped its history from the
|
|
/// screen as well as from the model would lose the only copy the
|
|
/// phone has -- so this is a divider, not a truncation, and the
|
|
/// events before it stay exactly where they were.
|
|
///
|
|
/// It is also what makes clearing mean the same thing for every
|
|
/// driver, which is why the marker lives here rather than in one
|
|
/// dialect: `llama` folds its conversation out of the transcript and
|
|
/// simply folds from the last one of these, and `claude` starts a new
|
|
/// CLI conversation behind it.
|
|
///
|
|
/// **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 the reason to write this
|
|
/// down rather than leave it to be inferred from a second example
|
|
/// that does not exist yet.
|
|
Cleared,
|
|
Error {
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
/// 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 --
|
|
/// `/context`, `/usage` -- 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,
|
|
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. It resolves itself -- the driver keeps asking -- so what
|
|
/// it means to a reader is "wait", not "act".
|
|
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, images: Vec<ImageRef>);
|
|
/// Answers one question with everything that was chosen, in the order
|
|
/// it was offered. One answer is a list of one; 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 actually changes the setting owes an
|
|
// [`Event::Settings`] once it has -- that event, and not the request,
|
|
// is what the manager and the phone read. One that cannot change it
|
|
// owes an [`Event::Error`] saying why; saying nothing leaves a phone
|
|
// showing a setting nobody applied.
|
|
|
|
/// 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 own config, which is
|
|
/// what a phone lists and the only place the name has to be. So a
|
|
/// driver whose process has no notion of a name does nothing here and
|
|
/// says nothing -- there is no failure to report, and an error beside
|
|
/// a rename that plainly worked would be a puzzle rather than a
|
|
/// warning.
|
|
///
|
|
/// Claude Code has one: `--name` when a session is created 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.
|
|
///
|
|
/// For the ones this app has no opinion about -- `/context`, `/usage`,
|
|
/// anything a CLI adds next month. A driver whose process has 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 instead.
|
|
///
|
|
/// Like [`Driver::compact`] and [`Driver::set_title`], this is called
|
|
/// only when the session is between turns; the waiting is done above,
|
|
/// once, for every driver.
|
|
fn run_command(&self, text: &str);
|
|
/// pi: native compaction; claude: `/compact`.
|
|
fn compact(&self);
|
|
|
|
/// Drops the conversation so far without ending the session.
|
|
///
|
|
/// The cheap half of managing a long session, and the reason 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 a single 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 when it comes
|
|
/// back.
|
|
///
|
|
/// This is deliberately not a shutdown. A backend restart -- a
|
|
/// rebuild, a service restart, a crash -- 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`. A driver with no
|
|
/// process of its own has nothing to do here.
|
|
///
|
|
/// 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".
|
|
fn detach(&self);
|
|
/// End the process for good, because the session it belongs to is
|
|
/// being deleted. The path out for everything [`detach`] preserves.
|
|
///
|
|
/// [`detach`]: Driver::detach
|
|
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",
|
|
})
|
|
);
|
|
}
|
|
}
|