Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
694535badc
3 files changed
+182
-3
No files matched your search
@@ -177,6 +177,52 @@ sealed class TranscriptItem {
|
||||
) : TranscriptItem()
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a page of older items in front of the ones already loaded, healing any tool call the page
|
||||
* boundary cut in two.
|
||||
*
|
||||
* A boundary lands wherever it lands, and roughly half the time that is between a call and its
|
||||
* result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws
|
||||
* as a row of its own -- correctly, because a call that renders as nothing is indistinguishable
|
||||
* from one that never happened. When the older page arrives it brings the real `ToolStart`, and
|
||||
* concatenating the two lists left *both*: the same call twice, once as a proper card and once as a
|
||||
* nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than
|
||||
* the miscount -- the extra row is at the join, so it also moves everything the reader was looking
|
||||
* at.
|
||||
*
|
||||
* Merged by the call's own id rather than by position, because position is exactly what a page
|
||||
* boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the
|
||||
* newer on what an end knows (the output, and whether it finished), which is the only way round
|
||||
* that loses nothing.
|
||||
*/
|
||||
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
|
||||
val startedEarlier =
|
||||
earlier.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
|
||||
if (startedEarlier.isEmpty()) return earlier + later
|
||||
val endedLater =
|
||||
later
|
||||
.filterIsInstance<TranscriptItem.ToolRun>()
|
||||
.associateBy { it.id }
|
||||
.filterKeys { it in startedEarlier }
|
||||
if (endedLater.isEmpty()) return earlier + later
|
||||
val healed = earlier.map { row ->
|
||||
val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] }
|
||||
if (row is TranscriptItem.ToolRun && half != null) {
|
||||
row.copy(
|
||||
output = half.output,
|
||||
done = half.done,
|
||||
// Kept from both halves: a question or an image can be attached to either,
|
||||
// depending on which side of the boundary its event fell.
|
||||
asks = row.asks + half.asks,
|
||||
images = row.images + half.images,
|
||||
)
|
||||
} else {
|
||||
row
|
||||
}
|
||||
}
|
||||
return healed + later.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater }
|
||||
}
|
||||
|
||||
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
|
||||
when (val event = entry.event) {
|
||||
is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(entry.seq, event.text)
|
||||
@@ -636,7 +682,7 @@ fun SessionScreen(
|
||||
earlier = foldEvent(earlier, entry)
|
||||
}
|
||||
}
|
||||
items = earlier + items
|
||||
items = joinPages(earlier, items)
|
||||
}
|
||||
} catch (_: ApiException) {
|
||||
// Leave `moreHistory` alone: the next scroll asks again.
|
||||
|
||||
+134
-1
@@ -25,6 +25,12 @@
|
||||
//! This is exactly the event vocabulary the real drivers produce, so a UI
|
||||
//! that renders echo sessions correctly renders the real thing.
|
||||
//!
|
||||
//! - `/mixed N` -- N beats of an interleaved transcript: paragraphs of
|
||||
//! different lengths, single tool calls, runs of adjacent ones, images
|
||||
//! and a peer message. Rows of every shape and height the app draws, in
|
||||
//! one session, which is what a scrolling problem needs in order to be
|
||||
//! reproduced twice the same way.
|
||||
//!
|
||||
//! `/slow` earns its place: a queued message, a Stop button, a spinner
|
||||
//! where the answer will go are all states that only exist mid-turn, and
|
||||
//! the obvious way to get one -- ask a real model to sleep -- does not
|
||||
@@ -33,6 +39,7 @@
|
||||
//! account. A driver that can be *told* to take its time costs nothing and
|
||||
//! is the same every run.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
@@ -76,6 +83,9 @@ pub struct EchoDriver {
|
||||
/// Held messages with the id of the `MessageQueued` each one announced,
|
||||
/// so the announcement can say which waiting bubble it resolves.
|
||||
queued: Arc<Mutex<Vec<(String, String)>>>,
|
||||
/// Where `/mixed` writes the images it references, which is the same
|
||||
/// directory the files route serves them from.
|
||||
session_dir: PathBuf,
|
||||
/// Ids of the questions awaiting an answer, in the order they were
|
||||
/// asked. A list because `/ask` puts up to four on one tool call, the
|
||||
/// way AskUserQuestion does, and the turn resumes when the last of
|
||||
@@ -316,6 +326,9 @@ impl EchoDriver {
|
||||
// rather than trusted: this is a test affordance, and a session
|
||||
// pinned running for an hour by a typo is a worse outcome than a
|
||||
// short wait.
|
||||
let mixed = text
|
||||
.strip_prefix("/mixed")
|
||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400));
|
||||
let linger = text.strip_prefix("/slow").map(|rest| {
|
||||
Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600))
|
||||
});
|
||||
@@ -324,6 +337,7 @@ impl EchoDriver {
|
||||
.map(|rest| rest.trim().to_string());
|
||||
let busy = Arc::clone(&self.busy);
|
||||
let queued = Arc::clone(&self.queued);
|
||||
let dir = self.session_dir.clone();
|
||||
busy.store(true, Ordering::SeqCst);
|
||||
tokio::spawn(async move {
|
||||
let send = |event: Event| {
|
||||
@@ -396,6 +410,14 @@ impl EchoDriver {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(beats) = mixed {
|
||||
for beat in 1..=beats {
|
||||
write_beat(&sink, &dir, beat).await;
|
||||
}
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(input) = run_tool {
|
||||
let id = format!("t-{}", super::random_hex());
|
||||
send(Event::ToolStart {
|
||||
@@ -429,12 +451,13 @@ impl EchoDriver {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn new(sink: EventSink) -> Self {
|
||||
pub fn new(sink: EventSink, session_dir: PathBuf) -> Self {
|
||||
let driver = Self {
|
||||
sink,
|
||||
pending_questions: Mutex::new(Vec::new()),
|
||||
busy: Arc::new(AtomicBool::new(false)),
|
||||
queued: Arc::new(Mutex::new(Vec::new())),
|
||||
session_dir,
|
||||
};
|
||||
driver.emit(Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
@@ -450,6 +473,116 @@ impl EchoDriver {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 16x10 checkerboard, the smallest thing that is recognisably an image
|
||||
/// rather than a blank rectangle.
|
||||
///
|
||||
/// Embedded rather than generated because the alternative is a PNG encoder
|
||||
/// in a test rig, and drawn at the transcript's fixed thumbnail height
|
||||
/// anyway -- what a scroll test needs from an image is that it occupies an
|
||||
/// image's worth of space, not that it is pretty.
|
||||
const SAMPLE_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAKCAIAAAAy3EnLAAAAIklEQVR42mPo3PILiOTk9ICIGDYDyRqIVwphk65h1A9EsAGCYdJRj+JH4wAAAABJRU5ErkJggg==";
|
||||
|
||||
/// One beat of `/mixed`: a row shape chosen by position, so the same N
|
||||
/// always produces the same transcript.
|
||||
///
|
||||
/// Repeatable on purpose. A scrolling fault is judged by watching the same
|
||||
/// content behave differently, and a rig that produced a different
|
||||
/// transcript each run would make every comparison an argument about
|
||||
/// whether the content changed.
|
||||
async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
||||
let send = |event: Event| {
|
||||
let _ = sink.send(event);
|
||||
};
|
||||
match beat % 5 {
|
||||
// A paragraph, of three lengths, because a list of uniform rows
|
||||
// hides exactly the faults that uneven ones expose.
|
||||
1 => {
|
||||
let words = match beat % 3 {
|
||||
0 => 12,
|
||||
1 => 60,
|
||||
_ => 220,
|
||||
};
|
||||
// Deliberately ragged: each word's length is a function of its
|
||||
// position, so no two lines wrap the same way. A paragraph of
|
||||
// uniform tokens is a wall that looks identical at every
|
||||
// offset, which makes it impossible to tell a scroll of one
|
||||
// line from a scroll of ten -- by eye or by comparing frames.
|
||||
let body: String = (0..words)
|
||||
.map(|w| {
|
||||
let len = 3 + (w * 7 + beat * 3) % 14;
|
||||
format!("{beat}.{w}{} ", "x".repeat(len))
|
||||
})
|
||||
.collect();
|
||||
send(Event::AssistantText {
|
||||
delta: format!("\n\nParagraph at beat {beat}:\n{body}"),
|
||||
});
|
||||
}
|
||||
// One call on its own -- drawn as a card rather than a group.
|
||||
2 => {
|
||||
let id = format!("t-{}", super::random_hex());
|
||||
send(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: "Read".to_string(),
|
||||
input: serde_json::json!({ "file_path": format!("/tmp/beat-{beat}.txt") }),
|
||||
});
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("beat {beat}: forty-two lines of nothing in particular"),
|
||||
});
|
||||
}
|
||||
// A run of three, which the app folds into one collapsed group --
|
||||
// the row whose identity depends on what is next to it.
|
||||
3 => {
|
||||
for i in 1..=3 {
|
||||
let id = format!("t-{}", super::random_hex());
|
||||
send(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: if i % 2 == 0 { "Bash" } else { "Grep" }.to_string(),
|
||||
input: serde_json::json!({ "command": format!("grep -rn 'beat {beat}' /tmp") }),
|
||||
});
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("beat {beat}, call {i} of 3"),
|
||||
});
|
||||
}
|
||||
}
|
||||
// An image, under the call that produced it, which is where a real
|
||||
// screenshot lands.
|
||||
4 => {
|
||||
let id = format!("t-{}", super::random_hex());
|
||||
send(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: "Screenshot".to_string(),
|
||||
input: serde_json::json!({ "description": format!("beat {beat}") }),
|
||||
});
|
||||
let part = serde_json::json!({
|
||||
"source": {"media_type": "image/png", "data": SAMPLE_PNG}
|
||||
});
|
||||
if let Some(name) = super::claude::translate::save_image(session_dir, &part) {
|
||||
send(Event::Image {
|
||||
image: name,
|
||||
about: Some(id.clone()),
|
||||
});
|
||||
}
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("beat {beat}: captured"),
|
||||
});
|
||||
}
|
||||
// Somebody else's voice, which is its own row shape.
|
||||
_ => {
|
||||
send(Event::PeerMessage {
|
||||
from: format!("beat-{beat}-peer"),
|
||||
text: format!("Message {beat} from another session, for the row it becomes."),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Slow enough that the phone renders each beat as it arrives rather
|
||||
// than composing the whole run in one frame -- which is the condition
|
||||
// a scrolling fault actually happens under.
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
}
|
||||
|
||||
/// Ending a turn is also when anything held during it is taken up -- the
|
||||
/// moment a real CLI would have injected it. One place, because a turn has
|
||||
/// several ways to end (a reply, an interrupt, a compaction) and every one
|
||||
|
||||
@@ -1235,7 +1235,7 @@ fn launch(
|
||||
}
|
||||
|
||||
let driver: Arc<dyn Driver> = match provider.kind {
|
||||
DriverKind::Echo => Arc::new(EchoDriver::new(sink.clone())),
|
||||
DriverKind::Echo => Arc::new(EchoDriver::new(sink.clone(), dir.clone())),
|
||||
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
|
||||
&meta,
|
||||
provider,
|
||||
|
||||
Reference in new issue
Block a user