Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
a49120b0c8
9 files changed
+177
-48
No files matched your search
@@ -20,7 +20,28 @@ data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
|
|||||||
data class QuestionOption(val label: String, val description: String?, val preview: String?)
|
data class QuestionOption(val label: String, val description: String?, val preview: String?)
|
||||||
|
|
||||||
sealed class SessionEvent {
|
sealed class SessionEvent {
|
||||||
data class UserMessage(val text: String) : SessionEvent()
|
data class UserMessage(
|
||||||
|
val text: String,
|
||||||
|
/**
|
||||||
|
* The [MessageQueued] this resolves, or null when it never waited.
|
||||||
|
*
|
||||||
|
* Matched on rather than the text, because the same message sent twice is two waiting
|
||||||
|
* bubbles and clearing whichever one matched first would leave the wrong one on screen.
|
||||||
|
*/
|
||||||
|
val id: String?,
|
||||||
|
) : SessionEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message the server has accepted and the session has not read yet.
|
||||||
|
*
|
||||||
|
* From the server, not from this app's memory of what it sent. The pending bubble used to be
|
||||||
|
* screen state, so leaving the session or restarting the app drew nothing waiting while the
|
||||||
|
* message was still queued -- and nothing waiting is what "there is nothing" looks like.
|
||||||
|
*
|
||||||
|
* Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by
|
||||||
|
* [CommandSent].
|
||||||
|
*/
|
||||||
|
data class MessageQueued(val id: String, val text: String) : SessionEvent()
|
||||||
|
|
||||||
data class AssistantText(val delta: String) : SessionEvent()
|
data class AssistantText(val delta: String) : SessionEvent()
|
||||||
|
|
||||||
@@ -119,7 +140,13 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
val body = JSONObject(json)
|
val body = JSONObject(json)
|
||||||
val event =
|
val event =
|
||||||
when (val type = body.getString("type")) {
|
when (val type = body.getString("type")) {
|
||||||
"userMessage" -> SessionEvent.UserMessage(body.getString("text"))
|
"userMessage" ->
|
||||||
|
SessionEvent.UserMessage(
|
||||||
|
body.getString("text"),
|
||||||
|
body.optString("id").ifEmpty { null },
|
||||||
|
)
|
||||||
|
"messageQueued" ->
|
||||||
|
SessionEvent.MessageQueued(body.getString("id"), body.getString("text"))
|
||||||
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
|
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
|
||||||
"toolStart" ->
|
"toolStart" ->
|
||||||
SessionEvent.ToolStart(
|
SessionEvent.ToolStart(
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
|
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
|
||||||
// Screen-level state, not transcript rows -- see SessionScreen.
|
// Screen-level state, not transcript rows -- see SessionScreen.
|
||||||
is SessionEvent.CommandQueued -> items
|
is SessionEvent.CommandQueued -> items
|
||||||
|
// No row of its own: a message that is still waiting is drawn as a pending bubble below
|
||||||
|
// the transcript, and becomes an ordinary one where the session read it.
|
||||||
|
is SessionEvent.MessageQueued -> items
|
||||||
is SessionEvent.Settings -> items
|
is SessionEvent.Settings -> items
|
||||||
is SessionEvent.Status -> items
|
is SessionEvent.Status -> items
|
||||||
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
||||||
@@ -373,7 +376,11 @@ fun SessionScreen(
|
|||||||
// the working indicator, because that is where it is in the session's
|
// the working indicator, because that is where it is in the session's
|
||||||
// reading of events: after everything taken in, not yet taken in
|
// reading of events: after everything taken in, not yet taken in
|
||||||
// itself.
|
// itself.
|
||||||
var queued by remember { mutableStateOf(listOf<String>()) }
|
// Messages the server has taken and the session has not read yet, by the id that will resolve
|
||||||
|
// them. From the event stream rather than from what this screen sent, so they are still here
|
||||||
|
// after leaving the session or restarting the app -- and so a message sent from another device
|
||||||
|
// is drawn waiting on this one too.
|
||||||
|
var queued by remember { mutableStateOf(listOf<Pair<String, String>>()) }
|
||||||
// Commands the session has been asked to run and cannot yet, by the id that will resolve
|
// Commands the session has been asked to run and cannot yet, by the id that will resolve
|
||||||
// them. From the server rather than from this screen, so a rename sent from the settings
|
// them. From the server rather than from this screen, so a rename sent from the settings
|
||||||
// screen -- or from another device -- is drawn waiting here too.
|
// screen -- or from another device -- is drawn waiting here too.
|
||||||
@@ -426,7 +433,14 @@ fun SessionScreen(
|
|||||||
// all that distinguishes one message from an identical
|
// all that distinguishes one message from an identical
|
||||||
// earlier one -- and only the first match, so two
|
// earlier one -- and only the first match, so two
|
||||||
// identical messages wait twice.
|
// identical messages wait twice.
|
||||||
if (event is SessionEvent.UserMessage) queued = queued - event.text
|
// Waiting, then read. Matched by id: the same message sent twice is two
|
||||||
|
// bubbles, and clearing by text would take away whichever matched first.
|
||||||
|
if (event is SessionEvent.MessageQueued) {
|
||||||
|
queued = queued + (event.id to event.text)
|
||||||
|
}
|
||||||
|
if (event is SessionEvent.UserMessage) {
|
||||||
|
queued = queued.filterNot { it.first == event.id }
|
||||||
|
}
|
||||||
// Waiting, then gone: a command leaves this list when the session takes it,
|
// Waiting, then gone: a command leaves this list when the session takes it,
|
||||||
// and the row it becomes is added by `foldEvent` in the same pass.
|
// and the row it becomes is added by `foldEvent` in the same pass.
|
||||||
if (event is SessionEvent.CommandQueued) {
|
if (event is SessionEvent.CommandQueued) {
|
||||||
@@ -689,18 +703,11 @@ fun SessionScreen(
|
|||||||
input = ""
|
input = ""
|
||||||
saveDraft(context, summary.id, "")
|
saveDraft(context, summary.id, "")
|
||||||
pendingAttachments = emptyList()
|
pendingAttachments = emptyList()
|
||||||
if (running && text.isNotEmpty()) queued = queued + text
|
// Nothing is added here. The server says what is waiting -- it emits `messageQueued`
|
||||||
// A held message leaves this list exactly two ways: the session
|
// when it takes a message it cannot deliver yet -- and this screen draws that. Holding a
|
||||||
// reads it, which comes back as a UserMessage (see `apply`), or the
|
// local copy as well was the bug: the two agreed only until the app was restarted or the
|
||||||
// send itself failed and there is nothing to wait for. Clearing the
|
// session left, and then the screen showed nothing pending while the queue was full.
|
||||||
// whole list when a turn ended was neither -- the server holds a
|
act { sendMessage(settings, summary.id, text, attachments) }
|
||||||
// queue of its own and takes one message per turn, so ending a turn
|
|
||||||
// is precisely when the *rest* are still waiting. It wiped them off
|
|
||||||
// the screen while they were on their way, which reads as messages
|
|
||||||
// two and three having been dropped.
|
|
||||||
act(onFailure = { queued = queued - text }) {
|
|
||||||
sendMessage(settings, summary.id, text, attachments)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The system photo picker; the image uploads as soon as it's chosen,
|
// The system photo picker; the image uploads as soon as it's chosen,
|
||||||
@@ -827,7 +834,7 @@ fun SessionScreen(
|
|||||||
waitingCommands.forEach { (_, text) ->
|
waitingCommands.forEach { (_, text) ->
|
||||||
CommandBubble(text, waiting = true)
|
CommandBubble(text, waiting = true)
|
||||||
}
|
}
|
||||||
queued.forEach { text -> UserBubble(text, pending = true) }
|
queued.forEach { (_, text) -> UserBubble(text, pending = true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,8 +136,10 @@ const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
|
|||||||
struct Queue {
|
struct Queue {
|
||||||
/// A turn is in flight, so a message sent now is a steer into it.
|
/// A turn is in flight, so a message sent now is a steer into it.
|
||||||
running: bool,
|
running: bool,
|
||||||
/// Written, not yet announced, oldest first.
|
/// Written, not yet announced, oldest first, each with the id of the
|
||||||
awaiting: VecDeque<String>,
|
/// `MessageQueued` that told the phone it was waiting -- so the
|
||||||
|
/// announcement can name which bubble it resolves.
|
||||||
|
awaiting: VecDeque<(String, String)>,
|
||||||
/// The process is gone, so nothing can be taken up any more.
|
/// The process is gone, so nothing can be taken up any more.
|
||||||
///
|
///
|
||||||
/// Needed because every other way out of a turn is an `Idle` this
|
/// Needed because every other way out of a turn is an `Idle` this
|
||||||
@@ -157,7 +159,7 @@ impl Queue {
|
|||||||
fn close(&mut self, sink: &EventSink, why: &str) {
|
fn close(&mut self, sink: &EventSink, why: &str) {
|
||||||
self.closed = true;
|
self.closed = true;
|
||||||
self.running = false;
|
self.running = false;
|
||||||
let lost: Vec<String> = self.awaiting.drain(..).collect();
|
let lost: Vec<String> = self.awaiting.drain(..).map(|(_, text)| text).collect();
|
||||||
if lost.is_empty() {
|
if lost.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -514,16 +516,25 @@ impl Driver for ClaudeDriver {
|
|||||||
if queue.running {
|
if queue.running {
|
||||||
// Into the running turn, now. Announced when the CLI shows it
|
// Into the running turn, now. Announced when the CLI shows it
|
||||||
// has been round the model again -- see `Queue`.
|
// has been round the model again -- see `Queue`.
|
||||||
queue.awaiting.push_back(text);
|
//
|
||||||
|
// The *waiting* is recorded here, though, which is the one
|
||||||
|
// thing that must not be left to the phone to remember: it put
|
||||||
|
// the bubble on screen from its own state, so leaving the
|
||||||
|
// session or restarting the app drew nothing pending while a
|
||||||
|
// message was still in the queue.
|
||||||
|
let id = super::random_hex();
|
||||||
|
queue.awaiting.push_back((id.clone(), text.clone()));
|
||||||
drop(queue);
|
drop(queue);
|
||||||
|
let _ = self.sink.send(Event::MessageQueued { id, text });
|
||||||
self.send_line(line);
|
self.send_line(line);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queue.running = true;
|
queue.running = true;
|
||||||
drop(queue);
|
drop(queue);
|
||||||
// Nothing is in flight, so there is nothing to wait for: this
|
// Nothing is in flight, so there is nothing to wait for: this
|
||||||
// message *is* the turn about to start.
|
// message *is* the turn about to start, and it never had a
|
||||||
let _ = self.sink.send(Event::MessageTaken { text });
|
// `MessageQueued` to resolve.
|
||||||
|
let _ = self.sink.send(Event::MessageTaken { id: None, text });
|
||||||
let _ = self.sink.send(Event::Status {
|
let _ = self.sink.send(Event::Status {
|
||||||
state: SessionStatus::Running,
|
state: SessionStatus::Running,
|
||||||
});
|
});
|
||||||
@@ -930,12 +941,15 @@ fn proves_a_turn(event: &Event) -> bool {
|
|||||||
/// [`translate_line`], and the pair is the whole of the rule -- a steer
|
/// [`translate_line`], and the pair is the whole of the rule -- a steer
|
||||||
/// announced anywhere else lands above output that predates it.
|
/// announced anywhere else lands above output that predates it.
|
||||||
fn announce_steers(queue: &Arc<Mutex<Queue>>, sink: &EventSink) -> bool {
|
fn announce_steers(queue: &Arc<Mutex<Queue>>, sink: &EventSink) -> bool {
|
||||||
let taken: Vec<String> = {
|
let taken: Vec<(String, String)> = {
|
||||||
let mut queue = queue.lock().unwrap();
|
let mut queue = queue.lock().unwrap();
|
||||||
queue.awaiting.drain(..).collect()
|
queue.awaiting.drain(..).collect()
|
||||||
};
|
};
|
||||||
for text in taken {
|
for (id, text) in taken {
|
||||||
if sink.send(Event::MessageTaken { text }).is_err() {
|
if sink
|
||||||
|
.send(Event::MessageTaken { id: Some(id), text })
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1137,7 +1151,7 @@ mod tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.awaiting
|
.awaiting
|
||||||
.push_back("do the other one instead".into())
|
.push_back(("q1".into(), "do the other one instead".into()))
|
||||||
});
|
});
|
||||||
|
|
||||||
let at = |find: fn(&Event) -> bool| {
|
let at = |find: fn(&Event) -> bool| {
|
||||||
@@ -1147,6 +1161,16 @@ mod tests {
|
|||||||
.unwrap_or_else(|| panic!("nothing matched in {events:?}"))
|
.unwrap_or_else(|| panic!("nothing matched in {events:?}"))
|
||||||
};
|
};
|
||||||
let taken = at(|e| matches!(e, Event::MessageTaken { .. }));
|
let taken = at(|e| matches!(e, Event::MessageTaken { .. }));
|
||||||
|
// Named, not just announced: the phone has a waiting bubble on screen for this message
|
||||||
|
// and clears the one with this id. Matching on the text instead would clear the wrong
|
||||||
|
// bubble whenever the same thing was sent twice.
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
&events[taken],
|
||||||
|
Event::MessageTaken { id: Some(id), .. } if id == "q1"
|
||||||
|
),
|
||||||
|
"an announcement must name the queue entry it resolves: {events:?}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
taken > at(|e| matches!(e, Event::ToolStart { .. })),
|
taken > at(|e| matches!(e, Event::ToolStart { .. })),
|
||||||
"a steer must not sit above a call the model had already made: {events:?}"
|
"a steer must not sit above a call the model had already made: {events:?}"
|
||||||
@@ -1184,7 +1208,7 @@ mod tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.awaiting
|
.awaiting
|
||||||
.push_back("never mind".into())
|
.push_back(("q2".into(), "never mind".into()))
|
||||||
});
|
});
|
||||||
|
|
||||||
let taken = events
|
let taken = events
|
||||||
@@ -1347,8 +1371,8 @@ mod tests {
|
|||||||
running: true,
|
running: true,
|
||||||
..Queue::default()
|
..Queue::default()
|
||||||
};
|
};
|
||||||
queue.awaiting.push_back("first".into());
|
queue.awaiting.push_back(("q1".into(), "first".into()));
|
||||||
queue.awaiting.push_back("second".into());
|
queue.awaiting.push_back(("q2".into(), "second".into()));
|
||||||
queue.close(&sink, "the session ended");
|
queue.close(&sink, "the session ended");
|
||||||
|
|
||||||
// Named rather than counted, because these never reached the
|
// Named rather than counted, because these never reached the
|
||||||
|
|||||||
@@ -69,6 +69,34 @@ pub enum Event {
|
|||||||
/// one stream. Recorded when the session reads the message, which is
|
/// one stream. Recorded when the session reads the message, which is
|
||||||
/// what `MessageTaken` reports.
|
/// what `MessageTaken` reports.
|
||||||
UserMessage {
|
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,
|
text: String,
|
||||||
},
|
},
|
||||||
/// A driver has taken one of the user's messages and started reading
|
/// A driver has taken one of the user's messages and started reading
|
||||||
@@ -82,6 +110,9 @@ pub enum Event {
|
|||||||
/// predates it, and leaves a phone drawing it as still waiting with
|
/// predates it, and leaves a phone drawing it as still waiting with
|
||||||
/// nothing coming to say otherwise.
|
/// nothing coming to say otherwise.
|
||||||
MessageTaken {
|
MessageTaken {
|
||||||
|
/// The `MessageQueued` this answers, or `None` when it never
|
||||||
|
/// waited. Carried through onto the `UserMessage`.
|
||||||
|
id: Option<String>,
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
/// Streaming assistant text; the phone renders the concatenation as
|
/// Streaming assistant text; the phone renders the concatenation as
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ pub struct EchoDriver {
|
|||||||
/// dropped to idle immediately, so a phone had nothing to show as
|
/// dropped to idle immediately, so a phone had nothing to show as
|
||||||
/// pending. Holding it here is what makes echo able to stand in.
|
/// pending. Holding it here is what makes echo able to stand in.
|
||||||
busy: Arc<AtomicBool>,
|
busy: Arc<AtomicBool>,
|
||||||
queued: Arc<Mutex<Vec<String>>>,
|
/// 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)>>>,
|
||||||
/// 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
|
||||||
@@ -207,7 +209,15 @@ impl EchoDriver {
|
|||||||
// session went idle the instant one arrived, and every state that
|
// session went idle the instant one arrived, and every state that
|
||||||
// only exists while something is queued was untestable.
|
// only exists while something is queued was untestable.
|
||||||
if self.busy.load(Ordering::SeqCst) {
|
if self.busy.load(Ordering::SeqCst) {
|
||||||
self.queued.lock().unwrap().push(text);
|
// The waiting is recorded, exactly as the real driver records
|
||||||
|
// it: the phone draws its pending bubbles from the server, so
|
||||||
|
// an echo session has to produce the same events or the states
|
||||||
|
// it exists to exercise are not the app's real ones.
|
||||||
|
let id = super::random_hex();
|
||||||
|
self.queued.lock().unwrap().push((id.clone(), text.clone()));
|
||||||
|
if announce {
|
||||||
|
self.emit(Event::MessageQueued { id, text });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +229,10 @@ impl EchoDriver {
|
|||||||
// from the transcript is the one thing echo must not model.
|
// from the transcript is the one thing echo must not model.
|
||||||
if let Some(rest) = text.strip_prefix("/peer") {
|
if let Some(rest) = text.strip_prefix("/peer") {
|
||||||
if announce {
|
if announce {
|
||||||
self.emit(Event::MessageTaken { text: text.clone() });
|
self.emit(Event::MessageTaken {
|
||||||
|
id: None,
|
||||||
|
text: text.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
self.emit(Event::PeerMessage {
|
self.emit(Event::PeerMessage {
|
||||||
from: "dev-updater-f5".to_string(),
|
from: "dev-updater-f5".to_string(),
|
||||||
@@ -240,7 +253,7 @@ impl EchoDriver {
|
|||||||
// this is the typed path onto it.
|
// this is the typed path onto it.
|
||||||
if text.trim() == "/compact" {
|
if text.trim() == "/compact" {
|
||||||
if announce {
|
if announce {
|
||||||
self.emit(Event::MessageTaken { text });
|
self.emit(Event::MessageTaken { id: None, text });
|
||||||
}
|
}
|
||||||
self.compact();
|
self.compact();
|
||||||
return;
|
return;
|
||||||
@@ -248,7 +261,7 @@ impl EchoDriver {
|
|||||||
|
|
||||||
if text.trim() == "/ask" {
|
if text.trim() == "/ask" {
|
||||||
if announce {
|
if announce {
|
||||||
self.emit(Event::MessageTaken { text });
|
self.emit(Event::MessageTaken { id: None, text });
|
||||||
}
|
}
|
||||||
self.ask_user_question();
|
self.ask_user_question();
|
||||||
return;
|
return;
|
||||||
@@ -322,7 +335,10 @@ impl EchoDriver {
|
|||||||
// message it thinks is still queued, and the point of an echo
|
// message it thinks is still queued, and the point of an echo
|
||||||
// provider is that it behaves like the real ones.
|
// provider is that it behaves like the real ones.
|
||||||
if announce {
|
if announce {
|
||||||
send(Event::MessageTaken { text: text.clone() });
|
send(Event::MessageTaken {
|
||||||
|
id: None,
|
||||||
|
text: text.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
send(Event::Status {
|
send(Event::Status {
|
||||||
state: SessionStatus::Running,
|
state: SessionStatus::Running,
|
||||||
@@ -438,14 +454,17 @@ impl EchoDriver {
|
|||||||
/// 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
|
||||||
/// of them owes the same answer.
|
/// of them owes the same answer.
|
||||||
fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<String>>, busy: &AtomicBool) {
|
fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<(String, String)>>, busy: &AtomicBool) {
|
||||||
let held = std::mem::take(&mut *queued.lock().unwrap());
|
let held = std::mem::take(&mut *queued.lock().unwrap());
|
||||||
for text in held {
|
for (id, text) in held {
|
||||||
// Announced before it is answered, in that order: a phone showing
|
// Announced before it is answered, in that order: a phone showing
|
||||||
// the message as pending needs the signal that it has been read,
|
// the message as pending needs the signal that it has been read,
|
||||||
// and the answer is meaningless above a message still drawn as
|
// and the answer is meaningless above a message still drawn as
|
||||||
// waiting.
|
// waiting.
|
||||||
let _ = sink.send(Event::MessageTaken { text: text.clone() });
|
let _ = sink.send(Event::MessageTaken {
|
||||||
|
id: Some(id),
|
||||||
|
text: text.clone(),
|
||||||
|
});
|
||||||
let _ = sink.send(Event::AssistantText {
|
let _ = sink.send(Event::AssistantText {
|
||||||
delta: format!("\n(taken from the queue) You said: {text}"),
|
delta: format!("\n(taken from the queue) You said: {text}"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -538,7 +538,9 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
|
|||||||
}
|
}
|
||||||
let text = text_of(content);
|
let text = text_of(content);
|
||||||
if !text.trim().is_empty() {
|
if !text.trim().is_empty() {
|
||||||
events.push(Event::UserMessage { text });
|
// Replayed from the CLI's own file: it was read long ago, so
|
||||||
|
// there is no waiting bubble for it to resolve.
|
||||||
|
events.push(Event::UserMessage { id: None, text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -344,7 +344,10 @@ impl Driver for LlamaDriver {
|
|||||||
// Nothing is ever held back here -- there is no queue to wait
|
// Nothing is ever held back here -- there is no queue to wait
|
||||||
// in -- so the message is taken the moment it arrives. Said
|
// in -- so the message is taken the moment it arrives. Said
|
||||||
// anyway, because this is what records it: see `MessageTaken`.
|
// anyway, because this is what records it: see `MessageTaken`.
|
||||||
let _ = sink.send(Event::MessageTaken { text: text.clone() });
|
let _ = sink.send(Event::MessageTaken {
|
||||||
|
id: None,
|
||||||
|
text: text.clone(),
|
||||||
|
});
|
||||||
let _ = sink.send(Event::Status {
|
let _ = sink.send(Event::Status {
|
||||||
state: SessionStatus::Running,
|
state: SessionStatus::Running,
|
||||||
});
|
});
|
||||||
@@ -474,7 +477,7 @@ fn conversation(path: &Path) -> Vec<Message> {
|
|||||||
};
|
};
|
||||||
for event in events.iter().cloned() {
|
for event in events.iter().cloned() {
|
||||||
match event.event {
|
match event.event {
|
||||||
Event::UserMessage { text } => {
|
Event::UserMessage { text, .. } => {
|
||||||
if !pending.is_empty() {
|
if !pending.is_empty() {
|
||||||
messages.push(Message {
|
messages.push(Message {
|
||||||
role: "assistant".into(),
|
role: "assistant".into(),
|
||||||
@@ -631,6 +634,7 @@ mod tests {
|
|||||||
fn deltas_between_user_messages_are_one_assistant_turn() {
|
fn deltas_between_user_messages_are_one_assistant_turn() {
|
||||||
let (_dir, path) = transcript_with(&[
|
let (_dir, path) = transcript_with(&[
|
||||||
Event::UserMessage {
|
Event::UserMessage {
|
||||||
|
id: None,
|
||||||
text: "hello".into(),
|
text: "hello".into(),
|
||||||
},
|
},
|
||||||
Event::AssistantText {
|
Event::AssistantText {
|
||||||
@@ -643,6 +647,7 @@ mod tests {
|
|||||||
state: SessionStatus::Idle,
|
state: SessionStatus::Idle,
|
||||||
},
|
},
|
||||||
Event::UserMessage {
|
Event::UserMessage {
|
||||||
|
id: None,
|
||||||
text: "again".into(),
|
text: "again".into(),
|
||||||
},
|
},
|
||||||
Event::AssistantText {
|
Event::AssistantText {
|
||||||
@@ -673,6 +678,7 @@ mod tests {
|
|||||||
fn an_interrupted_reply_stays_in_the_conversation() {
|
fn an_interrupted_reply_stays_in_the_conversation() {
|
||||||
let (_dir, path) = transcript_with(&[
|
let (_dir, path) = transcript_with(&[
|
||||||
Event::UserMessage {
|
Event::UserMessage {
|
||||||
|
id: None,
|
||||||
text: "count".into(),
|
text: "count".into(),
|
||||||
},
|
},
|
||||||
Event::AssistantText {
|
Event::AssistantText {
|
||||||
@@ -697,6 +703,7 @@ mod tests {
|
|||||||
state: SessionStatus::Running,
|
state: SessionStatus::Running,
|
||||||
},
|
},
|
||||||
Event::UserMessage {
|
Event::UserMessage {
|
||||||
|
id: None,
|
||||||
text: "hello".into(),
|
text: "hello".into(),
|
||||||
},
|
},
|
||||||
Event::Error {
|
Event::Error {
|
||||||
@@ -720,6 +727,7 @@ mod tests {
|
|||||||
fn the_conversation_starts_after_the_last_clear() {
|
fn the_conversation_starts_after_the_last_clear() {
|
||||||
let (_dir, path) = transcript_with(&[
|
let (_dir, path) = transcript_with(&[
|
||||||
Event::UserMessage {
|
Event::UserMessage {
|
||||||
|
id: None,
|
||||||
text: "the long expensive conversation".into(),
|
text: "the long expensive conversation".into(),
|
||||||
},
|
},
|
||||||
Event::AssistantText {
|
Event::AssistantText {
|
||||||
@@ -727,6 +735,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
Event::Cleared,
|
Event::Cleared,
|
||||||
Event::UserMessage {
|
Event::UserMessage {
|
||||||
|
id: None,
|
||||||
text: "a fresh start".into(),
|
text: "a fresh start".into(),
|
||||||
},
|
},
|
||||||
Event::AssistantText {
|
Event::AssistantText {
|
||||||
@@ -744,11 +753,18 @@ mod tests {
|
|||||||
/// first clear dropped.
|
/// first clear dropped.
|
||||||
fn only_the_newest_clear_counts() {
|
fn only_the_newest_clear_counts() {
|
||||||
let (_dir, path) = transcript_with(&[
|
let (_dir, path) = transcript_with(&[
|
||||||
Event::UserMessage { text: "one".into() },
|
Event::UserMessage {
|
||||||
Event::Cleared,
|
id: None,
|
||||||
Event::UserMessage { text: "two".into() },
|
text: "one".into(),
|
||||||
|
},
|
||||||
Event::Cleared,
|
Event::Cleared,
|
||||||
Event::UserMessage {
|
Event::UserMessage {
|
||||||
|
id: None,
|
||||||
|
text: "two".into(),
|
||||||
|
},
|
||||||
|
Event::Cleared,
|
||||||
|
Event::UserMessage {
|
||||||
|
id: None,
|
||||||
text: "three".into(),
|
text: "three".into(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1219,7 +1219,7 @@ async fn pump(
|
|||||||
// message here rather than being carried alongside it. One rule
|
// message here rather than being carried alongside it. One rule
|
||||||
// for where a user's message sits: where the session read it.
|
// for where a user's message sits: where the session read it.
|
||||||
let event = match event {
|
let event = match event {
|
||||||
Event::MessageTaken { text } => Event::UserMessage { text },
|
Event::MessageTaken { id, text } => Event::UserMessage { id, text },
|
||||||
other => other,
|
other => other,
|
||||||
};
|
};
|
||||||
// Nothing changed, so there is nothing to record. Both of these
|
// Nothing changed, so there is nothing to record. Both of these
|
||||||
@@ -1464,7 +1464,7 @@ mod tests {
|
|||||||
let user_at = seen
|
let user_at = seen
|
||||||
.iter()
|
.iter()
|
||||||
.position(|entry| {
|
.position(|entry| {
|
||||||
matches!(&entry.event, Event::UserMessage { text } if text == "hello there")
|
matches!(&entry.event, Event::UserMessage { text, .. } if text == "hello there")
|
||||||
})
|
})
|
||||||
.expect("user message in the stream");
|
.expect("user message in the stream");
|
||||||
let echoed: String = seen[user_at..]
|
let echoed: String = seen[user_at..]
|
||||||
|
|||||||
@@ -315,7 +315,10 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let path = dir.path().join("transcript.jsonl");
|
let path = dir.path().join("transcript.jsonl");
|
||||||
let events = vec![
|
let events = vec![
|
||||||
Event::UserMessage { text: "hi".into() },
|
Event::UserMessage {
|
||||||
|
id: None,
|
||||||
|
text: "hi".into(),
|
||||||
|
},
|
||||||
text("hello"),
|
text("hello"),
|
||||||
Event::ToolStart {
|
Event::ToolStart {
|
||||||
id: "t1".into(),
|
id: "t1".into(),
|
||||||
|
|||||||
Reference in new issue
Block a user