Let the server say what is waiting, instead of the phone remembering
A message sent into a running turn was drawn as a pending bubble from
screen state, so leaving the session or restarting the app showed nothing
waiting while the queue was full. Nothing waiting is what "there is
nothing" looks like -- the reader had no way to tell it from a queue that
had already drained, and Bryan hit exactly that: a message he sent
arrived, and his phone stopped showing it after a restart.
The server now records the waiting. `MessageQueued { id, text }` goes into
the transcript when a driver takes a message it cannot deliver yet, and
is resolved by the `UserMessage` carrying the same id -- the same shape
`CommandQueued` and `CommandSent` already had, so this is one more
instance of a mechanism rather than a second one beside it.
The message itself still lands where the session read it, which is what
the last change was about; only the *waiting* is recorded early. The two
are different facts and now have different events.
Paired by id rather than by text. The old code removed the bubble whose
text matched, so sending the same thing twice cleared the wrong one and
left a message on screen that had already been read.
Both drivers that can queue do it: the echo driver too, because the phone
now draws pending bubbles from the stream and a rig that skipped the
event would exercise a state the real app never sees.
Checked on the emulator: two messages sent into a `/slow` turn, then the
app force-stopped and relaunched -- both still drawn as waiting, in the
pending style, and both resolved into ordinary bubbles when the turn
ended and the session read them.
Still outstanding, and worth knowing: an entry outlives a *server*
restart in the transcript but not in the driver's memory, so a backend
restarted mid-queue would leave the bubble drawn with nothing coming to
resolve it. Before this change that message vanished from the transcript
entirely, so the failure is now visible rather than silent -- but it is
not yet right.
This commit is contained in:
1 parent
ba71c798f5
commit
e37e90a579
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?)
|
||||
|
||||
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()
|
||||
|
||||
@@ -119,7 +140,13 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
val body = JSONObject(json)
|
||||
val event =
|
||||
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"))
|
||||
"toolStart" ->
|
||||
SessionEvent.ToolStart(
|
||||
|
||||
@@ -270,6 +270,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
|
||||
// Screen-level state, not transcript rows -- see SessionScreen.
|
||||
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.Status -> items
|
||||
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
||||
@@ -375,7 +378,11 @@ fun SessionScreen(
|
||||
// the working indicator, because that is where it is in the session's
|
||||
// reading of events: after everything taken in, not yet taken in
|
||||
// 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
|
||||
// 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.
|
||||
@@ -428,7 +435,14 @@ fun SessionScreen(
|
||||
// all that distinguishes one message from an identical
|
||||
// earlier one -- and only the first match, so two
|
||||
// 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,
|
||||
// and the row it becomes is added by `foldEvent` in the same pass.
|
||||
if (event is SessionEvent.CommandQueued) {
|
||||
@@ -691,18 +705,11 @@ fun SessionScreen(
|
||||
input = ""
|
||||
saveDraft(context, summary.id, "")
|
||||
pendingAttachments = emptyList()
|
||||
if (running && text.isNotEmpty()) queued = queued + text
|
||||
// A held message leaves this list exactly two ways: the session
|
||||
// reads it, which comes back as a UserMessage (see `apply`), or the
|
||||
// send itself failed and there is nothing to wait for. Clearing the
|
||||
// whole list when a turn ended was neither -- the server holds a
|
||||
// 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)
|
||||
}
|
||||
// Nothing is added here. The server says what is waiting -- it emits `messageQueued`
|
||||
// when it takes a message it cannot deliver yet -- and this screen draws that. Holding a
|
||||
// local copy as well was the bug: the two agreed only until the app was restarted or the
|
||||
// session left, and then the screen showed nothing pending while the queue was full.
|
||||
act { sendMessage(settings, summary.id, text, attachments) }
|
||||
}
|
||||
|
||||
// The system photo picker; the image uploads as soon as it's chosen,
|
||||
@@ -816,7 +823,7 @@ fun SessionScreen(
|
||||
waitingCommands.forEach { (_, text) ->
|
||||
CommandBubble(text, waiting = true)
|
||||
}
|
||||
queued.forEach { text -> UserBubble(text, pending = true) }
|
||||
queued.forEach { (_, text) -> UserBubble(text, pending = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user