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()
|
) : 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> =
|
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
|
||||||
when (val event = entry.event) {
|
when (val event = entry.event) {
|
||||||
is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(entry.seq, event.text)
|
is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(entry.seq, event.text)
|
||||||
@@ -636,7 +682,7 @@ fun SessionScreen(
|
|||||||
earlier = foldEvent(earlier, entry)
|
earlier = foldEvent(earlier, entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items = earlier + items
|
items = joinPages(earlier, items)
|
||||||
}
|
}
|
||||||
} catch (_: ApiException) {
|
} catch (_: ApiException) {
|
||||||
// Leave `moreHistory` alone: the next scroll asks again.
|
// 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
|
//! This is exactly the event vocabulary the real drivers produce, so a UI
|
||||||
//! that renders echo sessions correctly renders the real thing.
|
//! 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
|
//! `/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
|
//! 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
|
//! 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
|
//! account. A driver that can be *told* to take its time costs nothing and
|
||||||
//! is the same every run.
|
//! is the same every run.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -76,6 +83,9 @@ pub struct EchoDriver {
|
|||||||
/// Held messages with the id of the `MessageQueued` each one announced,
|
/// Held messages with the id of the `MessageQueued` each one announced,
|
||||||
/// so the announcement can say which waiting bubble it resolves.
|
/// so the announcement can say which waiting bubble it resolves.
|
||||||
queued: Arc<Mutex<Vec<(String, String)>>>,
|
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
|
/// 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
|
/// 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
|
/// 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
|
// 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
|
// pinned running for an hour by a typo is a worse outcome than a
|
||||||
// short wait.
|
// 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| {
|
let linger = text.strip_prefix("/slow").map(|rest| {
|
||||||
Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600))
|
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());
|
.map(|rest| rest.trim().to_string());
|
||||||
let busy = Arc::clone(&self.busy);
|
let busy = Arc::clone(&self.busy);
|
||||||
let queued = Arc::clone(&self.queued);
|
let queued = Arc::clone(&self.queued);
|
||||||
|
let dir = self.session_dir.clone();
|
||||||
busy.store(true, Ordering::SeqCst);
|
busy.store(true, Ordering::SeqCst);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let send = |event: Event| {
|
let send = |event: Event| {
|
||||||
@@ -396,6 +410,14 @@ impl EchoDriver {
|
|||||||
return;
|
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 {
|
if let Some(input) = run_tool {
|
||||||
let id = format!("t-{}", super::random_hex());
|
let id = format!("t-{}", super::random_hex());
|
||||||
send(Event::ToolStart {
|
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 {
|
let driver = Self {
|
||||||
sink,
|
sink,
|
||||||
pending_questions: Mutex::new(Vec::new()),
|
pending_questions: Mutex::new(Vec::new()),
|
||||||
busy: Arc::new(AtomicBool::new(false)),
|
busy: Arc::new(AtomicBool::new(false)),
|
||||||
queued: Arc::new(Mutex::new(Vec::new())),
|
queued: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
session_dir,
|
||||||
};
|
};
|
||||||
driver.emit(Event::Status {
|
driver.emit(Event::Status {
|
||||||
state: SessionStatus::Idle,
|
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
|
/// 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
|
/// 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
|
/// 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 {
|
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(
|
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
|
||||||
&meta,
|
&meta,
|
||||||
provider,
|
provider,
|
||||||
|
|||||||
Reference in new issue
Block a user