Draw a model's thinking, and what a reply cost to produce
A llama.cpp session's `reasoning_content` becomes `Event::Thinking` deltas closed by an `Event::ThinkingDone` carrying the span the driver measured, and the phone draws it as a card of its own: "Thinking" with the spinner a running command has, then "Thought for 12.4s". Deliberately not a tool call, so a run of calls cannot collapse the reasoning into "Called 6 tools"; the reasoning is also kept out of the next prompt, which `conversation` already ignored. `UsageDelta` gains `tokensPerSecond`, the provider's own figure or nothing -- llama.cpp reports `timings.predicted_per_second` and the coding CLIs report no such thing -- and a finished reply carries a small line under it saying when it was sent and, where there is one, how fast it came out: "3:00 PM · 149 tok/s". The compact usage bar drops the provider's name for the window and puts its length after the time left instead: "42% · 3h 20m left / 5h". Three things that had to come with it: the transcript coalesces runs of thinking deltas as it does reply deltas, so one block is one row of a page rather than a page of its own; `joinPages` welds a block cut by a page boundary (`healSplitThinking`), since the half with no ending spun for ever; and `UsageDelta` now reaches the fold, which is what carries the rate to the reply. Verified on the emulator against a real Qwen3-0.6B session and the echo rig's new `/think [seconds]`: the spinner while it runs, "Thought for 1.4s" and "2:54 PM · 149 tok/s" after, the reasoning on tapping the card, and the usage bar reading "42% · 3h 19m left / 5h". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45f249ae91
commit
bb5ac1a242
18 files changed
+900
-101
No files matched your search
@@ -425,7 +425,11 @@ impl Translator {
|
||||
}
|
||||
let context = self.context.take();
|
||||
if tokens > 0 {
|
||||
events.push(Event::UsageDelta { tokens, context });
|
||||
events.push(Event::UsageDelta {
|
||||
tokens,
|
||||
context,
|
||||
tokens_per_second: None,
|
||||
});
|
||||
}
|
||||
// A level snapshot is authoritative at a turn boundary. In
|
||||
// particular, it repairs a task whose terminal edge was
|
||||
@@ -2548,7 +2552,8 @@ mod tests {
|
||||
vec![
|
||||
Event::UsageDelta {
|
||||
tokens: 182,
|
||||
context: None
|
||||
context: None,
|
||||
tokens_per_second: None,
|
||||
},
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle
|
||||
@@ -2590,7 +2595,8 @@ mod tests {
|
||||
},
|
||||
Event::UsageDelta {
|
||||
tokens: 7,
|
||||
context: None
|
||||
context: None,
|
||||
tokens_per_second: None,
|
||||
},
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle
|
||||
@@ -2644,6 +2650,7 @@ mod tests {
|
||||
Some(&Event::UsageDelta {
|
||||
tokens: 173,
|
||||
context: Some(26_131),
|
||||
tokens_per_second: None,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -2660,6 +2667,7 @@ mod tests {
|
||||
Some(&Event::UsageDelta {
|
||||
tokens: 13,
|
||||
context: None,
|
||||
tokens_per_second: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,6 +229,7 @@ impl Translator {
|
||||
tokens,
|
||||
// Cached input is a subset of this figure, not an additional count.
|
||||
context: last.get("inputTokens").and_then(Value::as_u64),
|
||||
tokens_per_second: None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect()
|
||||
@@ -245,6 +246,7 @@ impl Translator {
|
||||
events.push(Event::UsageDelta {
|
||||
tokens: input.unwrap_or(0) + output.unwrap_or(0),
|
||||
context: input,
|
||||
tokens_per_second: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -935,7 +937,8 @@ mod tests {
|
||||
events[0],
|
||||
Event::UsageDelta {
|
||||
tokens: 18,
|
||||
context: Some(13)
|
||||
context: Some(13),
|
||||
tokens_per_second: None,
|
||||
}
|
||||
);
|
||||
assert!(translator.completed());
|
||||
@@ -1157,7 +1160,8 @@ mod tests {
|
||||
)),
|
||||
vec![Event::UsageDelta {
|
||||
tokens: 42,
|
||||
context: Some(39)
|
||||
context: Some(39),
|
||||
tokens_per_second: None,
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1166,7 +1170,8 @@ mod tests {
|
||||
)),
|
||||
vec![Event::UsageDelta {
|
||||
tokens: 65,
|
||||
context: Some(61)
|
||||
context: Some(61),
|
||||
tokens_per_second: None,
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -212,6 +212,34 @@ pub enum Event {
|
||||
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,
|
||||
@@ -399,6 +427,17 @@ pub enum Event {
|
||||
/// 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>,
|
||||
},
|
||||
/// A compaction that finished, and how much context it recovered.
|
||||
///
|
||||
@@ -850,6 +889,7 @@ mod tests {
|
||||
Event::UsageDelta {
|
||||
tokens: 12,
|
||||
context: Some(30_100),
|
||||
tokens_per_second: None,
|
||||
}
|
||||
),
|
||||
Some(30_100)
|
||||
@@ -889,6 +929,7 @@ mod tests {
|
||||
Event::UsageDelta {
|
||||
tokens: 12,
|
||||
context: None,
|
||||
tokens_per_second: None,
|
||||
}
|
||||
),
|
||||
Some(30_100)
|
||||
|
||||
@@ -666,6 +666,12 @@ impl EchoDriver {
|
||||
let table = text
|
||||
.strip_prefix("/table")
|
||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(6).clamp(1, 12));
|
||||
// Seconds to spend thinking before the reply, default three. The rig
|
||||
// for the thinking card: a block that runs long enough to watch the
|
||||
// spinner, then ends with a duration to read.
|
||||
let think = text
|
||||
.strip_prefix("/think")
|
||||
.map(|rest| Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(3).clamp(1, 600)));
|
||||
let linger = text.strip_prefix("/slow").map(|rest| {
|
||||
Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600))
|
||||
});
|
||||
@@ -849,10 +855,31 @@ impl EchoDriver {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(think) = think {
|
||||
let started = std::time::Instant::now();
|
||||
for remaining in (1..=think.as_secs()).rev() {
|
||||
send(Event::Thinking {
|
||||
delta: format!(
|
||||
"Considering what to echo back, {remaining}s of it left. \
|
||||
The reply is the message, which took some working out.\n\n"
|
||||
),
|
||||
});
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
// Measured here for the same reason a driver measures it: the
|
||||
// phone can only see when an event arrived.
|
||||
send(Event::ThinkingDone {
|
||||
ms: started.elapsed().as_millis() as u64,
|
||||
});
|
||||
}
|
||||
|
||||
let streaming = std::time::Instant::now();
|
||||
let mut words = 0u64;
|
||||
for word in format!("You said: {text}").split_inclusive(' ') {
|
||||
send(Event::AssistantText {
|
||||
delta: word.to_string(),
|
||||
});
|
||||
words += 1;
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
}
|
||||
// A conversation gets bigger, so the pretend context does too:
|
||||
@@ -861,6 +888,12 @@ impl EchoDriver {
|
||||
send(Event::UsageDelta {
|
||||
tokens: spent,
|
||||
context: Some(context.fetch_add(spent + 100, Ordering::SeqCst) + spent + 100),
|
||||
// A real measurement of a pretend model: what this driver
|
||||
// emitted, over how long it took. A rig owes the app a figure
|
||||
// of the shape a real one has, not an invented value.
|
||||
tokens_per_second: Some(streaming.elapsed().as_secs_f64())
|
||||
.filter(|elapsed| *elapsed > 0.0)
|
||||
.map(|elapsed| words as f64 / elapsed),
|
||||
});
|
||||
finish();
|
||||
});
|
||||
|
||||
@@ -1562,9 +1562,12 @@ fn context_window(endpoint: &str) -> Option<u64> {
|
||||
/// what happens to them next -- asking, running, reporting -- is the caller's,
|
||||
/// and a call is not in the transcript until it has actually been made.
|
||||
///
|
||||
/// `reasoning_content` is dropped, which is what the Claude driver does with
|
||||
/// thinking deltas. A transcript is what was said, and this app does not draw
|
||||
/// a model's working.
|
||||
/// `reasoning_content` is a model's working, and it is emitted as
|
||||
/// [`Event::Thinking`] rather than mixed into the reply -- its own card, and
|
||||
/// deliberately not part of what the next prompt is built from (see
|
||||
/// [`conversation`], which ignores it). The block is closed with
|
||||
/// [`Event::ThinkingDone`] the moment the model says something else, which is
|
||||
/// how long it thought for.
|
||||
fn generate(
|
||||
endpoint: &str,
|
||||
messages: &[Message],
|
||||
@@ -1619,6 +1622,24 @@ fn generate(
|
||||
// worst direction for a figure somebody is watching to see how much room
|
||||
// is left.
|
||||
let mut context = None;
|
||||
// The open thinking block: when it started, so its duration is measured
|
||||
// where the deltas actually arrive rather than worked out later from two
|
||||
// timestamps. `None` between blocks -- a turn can think, speak, call a
|
||||
// tool and think again.
|
||||
let mut thinking: Option<std::time::Instant> = None;
|
||||
// What the server says its own generation ran at. Read from it rather than
|
||||
// divided out of the wall time here, which would count the request, the
|
||||
// prompt processing and this loop's own scheduling as generation.
|
||||
let mut per_second = None;
|
||||
// Closes the open block, which is anything the model says that is not more
|
||||
// working: the first word of the reply, or a tool call.
|
||||
let done_thinking = |thinking: &mut Option<std::time::Instant>| {
|
||||
if let Some(started) = thinking.take() {
|
||||
shared.emit(Event::ThinkingDone {
|
||||
ms: started.elapsed().as_millis() as u64,
|
||||
});
|
||||
}
|
||||
};
|
||||
for line in std::io::BufRead::lines(reader) {
|
||||
if shared.cancel.load(Ordering::SeqCst) {
|
||||
break;
|
||||
@@ -1642,12 +1663,27 @@ fn generate(
|
||||
tokens = total;
|
||||
context = Some(total);
|
||||
}
|
||||
if let Some(rate) = chunk
|
||||
.pointer("/timings/predicted_per_second")
|
||||
.and_then(Value::as_f64)
|
||||
{
|
||||
per_second = Some(rate);
|
||||
}
|
||||
let Some(delta) = chunk.pointer("/choices/0/delta") else {
|
||||
continue;
|
||||
};
|
||||
if let Some(fragment) = delta.get("reasoning_content").and_then(Value::as_str)
|
||||
&& !fragment.is_empty()
|
||||
{
|
||||
thinking.get_or_insert_with(std::time::Instant::now);
|
||||
shared.emit(Event::Thinking {
|
||||
delta: fragment.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(fragment) = delta.get("content").and_then(Value::as_str)
|
||||
&& !fragment.is_empty()
|
||||
{
|
||||
done_thinking(&mut thinking);
|
||||
text.push_str(fragment);
|
||||
shared.emit(Event::AssistantText {
|
||||
delta: fragment.to_string(),
|
||||
@@ -1659,11 +1695,20 @@ fn generate(
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
done_thinking(&mut thinking);
|
||||
absorb(&mut calls, fragment);
|
||||
}
|
||||
}
|
||||
// A block left open by the end of the stream -- a model that thought and
|
||||
// then said nothing, or a turn the reader cancelled -- is still a block
|
||||
// that ended. Without this its card spins for ever.
|
||||
done_thinking(&mut thinking);
|
||||
if tokens > 0 {
|
||||
shared.emit(Event::UsageDelta { tokens, context });
|
||||
shared.emit(Event::UsageDelta {
|
||||
tokens,
|
||||
context,
|
||||
tokens_per_second: per_second,
|
||||
});
|
||||
}
|
||||
// A call whose name never arrived is not a call. It happens when a stream
|
||||
// is cut mid-fragment, and running it would mean inventing what was asked
|
||||
@@ -1815,6 +1860,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A model's working is drawn and is deliberately not sent back to it: the
|
||||
/// prompt is what was said, and feeding reasoning back costs the whole of
|
||||
/// it in context for a model that never asked to see it again.
|
||||
fn thinking_is_not_part_of_the_conversation() {
|
||||
let (_dir, path) = transcript_with(&[
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "count".into(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
Event::Thinking {
|
||||
delta: "the user wants".into(),
|
||||
},
|
||||
Event::ThinkingDone { ms: 1200 },
|
||||
Event::AssistantText {
|
||||
delta: "one two".into(),
|
||||
},
|
||||
]);
|
||||
let messages = conversation(&path);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[1].content, "one two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The interrupted case, which decides what a resumed conversation is built
|
||||
/// from: whatever the phone was shown. The deltas that arrived before the
|
||||
@@ -1862,6 +1931,7 @@ mod tests {
|
||||
Event::UsageDelta {
|
||||
tokens: 12,
|
||||
context: Some(12),
|
||||
tokens_per_second: None,
|
||||
},
|
||||
]);
|
||||
let messages = conversation(&path);
|
||||
|
||||
@@ -388,7 +388,12 @@ impl<'a> Indexed<'a> {
|
||||
}
|
||||
|
||||
/// The newest `limit` *rows* ending at line `end`, with each run of
|
||||
/// consecutive [`Event::AssistantText`] deltas concatenated into one.
|
||||
/// consecutive deltas of one streamed kind concatenated into one.
|
||||
///
|
||||
/// Two kinds stream a token at a time -- [`Event::AssistantText`] and
|
||||
/// [`Event::Thinking`] -- and a run is of one of them, never of both: they
|
||||
/// are two rows on screen, and welding them would put a model's working
|
||||
/// inside what it said.
|
||||
///
|
||||
/// A reply is stored a token at a time, so a window counted in events is a
|
||||
/// fraction of a row for a reply and a whole row for a tool call, and the
|
||||
@@ -404,17 +409,22 @@ impl<'a> Indexed<'a> {
|
||||
fn parse_coalesced(&self, start: usize, end: usize, limit: usize) -> Result<Vec<SeqEvent>> {
|
||||
// Newest first while walking back, reversed to transcript order at the end.
|
||||
let mut out: Vec<SeqEvent> = Vec::new();
|
||||
// The run currently being gathered: its oldest seq/ts so far, and its deltas newest-first.
|
||||
let mut run: Option<(u64, f64, Vec<String>)> = None;
|
||||
let flush = |run: &mut Option<(u64, f64, Vec<String>)>, out: &mut Vec<SeqEvent>| {
|
||||
if let Some((seq, ts, mut deltas)) = run.take() {
|
||||
// The run currently being gathered: which kind it is, its oldest
|
||||
// seq/ts so far, and its deltas newest-first.
|
||||
let mut run: Option<Run> = None;
|
||||
let flush = |run: &mut Option<Run>, out: &mut Vec<SeqEvent>| {
|
||||
if let Some(Run {
|
||||
kind,
|
||||
seq,
|
||||
ts,
|
||||
mut deltas,
|
||||
}) = run.take()
|
||||
{
|
||||
deltas.reverse();
|
||||
out.push(SeqEvent {
|
||||
seq,
|
||||
ts,
|
||||
event: Event::AssistantText {
|
||||
delta: deltas.concat(),
|
||||
},
|
||||
event: kind.of_delta(deltas.concat()),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -428,19 +438,35 @@ impl<'a> Indexed<'a> {
|
||||
}
|
||||
index -= 1;
|
||||
let entry = self.parse_one(index)?;
|
||||
if let Event::AssistantText { delta } = entry.event {
|
||||
match run {
|
||||
Some((ref mut seq, ref mut ts, ref mut deltas)) => {
|
||||
*seq = entry.seq;
|
||||
*ts = entry.ts;
|
||||
deltas.push(delta);
|
||||
match Streamed::of(entry.event) {
|
||||
Ok((kind, delta)) => {
|
||||
// A run of a different kind ends here, whatever it was
|
||||
// gathering: the two are separate rows.
|
||||
if run.as_ref().is_some_and(|open| open.kind != kind) {
|
||||
flush(&mut run, &mut out);
|
||||
}
|
||||
match run {
|
||||
Some(ref mut open) => {
|
||||
open.seq = entry.seq;
|
||||
open.ts = entry.ts;
|
||||
open.deltas.push(delta);
|
||||
}
|
||||
None => {
|
||||
run = Some(Run {
|
||||
kind,
|
||||
seq: entry.seq,
|
||||
ts: entry.ts,
|
||||
deltas: vec![delta],
|
||||
})
|
||||
}
|
||||
}
|
||||
None => run = Some((entry.seq, entry.ts, vec![delta])),
|
||||
}
|
||||
} else {
|
||||
// The run above this event (newer) is complete: it is a row, and so is this event.
|
||||
flush(&mut run, &mut out);
|
||||
out.push(entry);
|
||||
Err(event) => {
|
||||
// The run above this event (newer) is complete: it is a row, and so is this
|
||||
// event.
|
||||
flush(&mut run, &mut out);
|
||||
out.push(SeqEvent { event, ..entry });
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut run, &mut out);
|
||||
@@ -449,6 +475,41 @@ impl<'a> Indexed<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// One run of same-kind deltas being gathered by [`Indexed::parse_coalesced`].
|
||||
struct Run {
|
||||
kind: Streamed,
|
||||
seq: u64,
|
||||
ts: f64,
|
||||
deltas: Vec<String>,
|
||||
}
|
||||
|
||||
/// The event kinds that arrive a fragment at a time and are read as one row.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Streamed {
|
||||
Text,
|
||||
Thinking,
|
||||
}
|
||||
|
||||
impl Streamed {
|
||||
/// The kind and fragment of a streamed event, or the event back unchanged
|
||||
/// when it is not one -- so the caller cannot forget to put it back.
|
||||
fn of(event: Event) -> std::result::Result<(Self, String), Event> {
|
||||
match event {
|
||||
Event::AssistantText { delta } => Ok((Self::Text, delta)),
|
||||
Event::Thinking { delta } => Ok((Self::Thinking, delta)),
|
||||
other => Err(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// The run put back together as the event it was a run of.
|
||||
fn of_delta(self, delta: String) -> Event {
|
||||
match self {
|
||||
Self::Text => Event::AssistantText { delta },
|
||||
Self::Thinking => Event::Thinking { delta },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -695,6 +756,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Thinking streams a fragment at a time exactly as a reply does, so a page
|
||||
/// counted in rows has to coalesce it too -- otherwise one block of working
|
||||
/// is a whole page of near-duplicate events. And the two runs stay two: a
|
||||
/// weld across the boundary would put the model's working inside what it
|
||||
/// said, in the prompt as well as on screen.
|
||||
fn thinking_deltas_coalesce_into_a_row_of_their_own() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("transcript.jsonl");
|
||||
let mut transcript = Transcript::open(&path).expect("append");
|
||||
for delta in ["think", "ing"] {
|
||||
transcript
|
||||
.append(
|
||||
Event::Thinking {
|
||||
delta: delta.into(),
|
||||
},
|
||||
0.0,
|
||||
)
|
||||
.expect("append");
|
||||
}
|
||||
transcript
|
||||
.append(Event::ThinkingDone { ms: 1200 }, 0.0)
|
||||
.expect("append");
|
||||
for delta in ["said", " it"] {
|
||||
transcript.append(text(delta), 0.0).expect("append");
|
||||
}
|
||||
|
||||
// `before` past the end, since coalescing is only ever done on settled
|
||||
// history -- see [`read_window`].
|
||||
let rows = read_window(&path, Some(6), None, 10, true).expect("window");
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert!(matches!(
|
||||
&rows[0],
|
||||
SeqEvent { seq: 1, event: Event::Thinking { delta }, .. } if delta == "thinking"
|
||||
));
|
||||
assert!(matches!(
|
||||
&rows[1],
|
||||
SeqEvent {
|
||||
seq: 3,
|
||||
event: Event::ThinkingDone { ms: 1200 },
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
&rows[2],
|
||||
SeqEvent { seq: 4, event: Event::AssistantText { delta }, .. } if delta == "said it"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_floor_inside_a_delta_run_leaves_the_partial_run_it_cuts() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -886,6 +996,7 @@ mod tests {
|
||||
Event::UsageDelta {
|
||||
tokens: 42,
|
||||
context: Some(42),
|
||||
tokens_per_second: None,
|
||||
},
|
||||
Event::AuthenticationRequired {
|
||||
message: "sign in again".into(),
|
||||
|
||||
Reference in new issue
Block a user