Select any of the transcript, and take a queued message back

Two things a reader could not do to what is on screen.

**Selection.** Nothing in the transcript was selectable at all, so a
command, a path or an error message could be read and not copied. One
`SelectionContainer` around the whole list rather than one per row: a
transcript is one body of text to a reader, and a selection has to be able
to run from a reply into the tool output under it. Per row it also could
not, and whatever was drawn without a container would have been silently
unselectable -- a state nothing on screen reports. Rows keep their tap
handlers; checked on the emulator that expanding a tool call, scrolling and
flinging are all unaffected, since a selection is a long press.

**Taking a message back.** A message sent into a running turn sits as a
bubble waiting to be read, and there was no way to change your mind: it is
tappable now, and the server answers `POST /sessions/{id}/unqueue`.

The answer has three states, and the middle one is the point. Claude's
driver writes a steer into the CLI's stdin the instant it arrives -- that
is what makes it reach the model at the next tool boundary rather than at
the end of the turn, and it was measured -- so the line is already gone and
`AlreadySent` is the only honest answer it can give. Holding the write
until a boundary would make the drop real and cost a steer one model call,
which is the latency the immediate write exists to remove; rejected on that
trade, with the reasoning in PLAN.md. The refusal is drawn on the bubble
that was pressed rather than in the error row under the header, a screen
away from it.

Where a driver really does hold its queue -- echo today -- the message goes
for good, and it goes as an `Event::MessageDropped` rather than as a return
value: every device watching the session loses the bubble, and a phone that
reconnects and replays the `messageQueued` does not put back one that was
cancelled with nothing left to resolve it.
This commit is contained in:
iris committed 2026-08-31 22:20:47 -04:00
1 parent 778b2e3b04
commit 82401cd887
12 files changed
+401 -10

No files matched your search

+14
View File
@@ -245,6 +245,20 @@ first if a remote spawn ever mangles an argument.
on the same transcript. What that costs is that the rows below slide up on the same transcript. What that costs is that the rows below slide up
under the reader's finger, so a row that has just moved ignores taps for under the reader's finger, so a row that has just moved ignores taps for
half a second (`SETTLE_MS`). half a second (`SETTLE_MS`).
- **All transcript text is selectable, from one `SelectionContainer` around
the whole list** (`TranscriptList.kt`). Not per row: a transcript is one
body of text to a reader, so a selection has to be able to run from a
reply into the tool output under it -- and a container per row leaves
whatever was drawn without one silently unselectable, which nothing on
screen reports. Rows keep their tap handlers; selection is a long press.
- **A queued message can be tapped to take it back**, which is
`POST /sessions/{id}/unqueue` and a `messageDropped` event -- see PLAN.md's
"Taking a queued message back". On a **Claude** session it always refuses,
and that is correct rather than broken: the driver writes a steer into the
CLI the moment it arrives, so what the bubble is waiting for is the CLI
*reading* it, not this server sending it. The refusal is drawn on the
bubble. The echo driver really does hold its queue, so that is the rig for
the case where the drop succeeds.
- **Deleting a session offers to take the machine's own transcript with - **Deleting a session offers to take the machine's own transcript with
it.** `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the it.** `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
confirmation, and only where the driver keeps a record of its own confirmation, and only where the driver keeps a record of its own
+30
View File
@@ -247,6 +247,35 @@ turn. Claude's dialect: a `user` message on stdin mid-stream; pi's: `steer`.
- Images in: base64 image content blocks in the stream-json user message. - Images in: base64 image content blocks in the stream-json user message.
- Working directory, host, and model are spawn-screen fields. - Working directory, host, and model are spawn-screen fields.
### Taking a queued message back (decided 2026-08-31)
A message sent into a running turn is drawn as a bubble waiting below the
transcript, and tapping it asks the server to drop it before the session
reads it — `POST /sessions/{id}/unqueue {messageId}`, answered by
`Driver::unqueue` and recorded as `Event::MessageDropped` so that every
device watching loses the bubble and a reconnect does not replay it back.
The answer has **three** states rather than a yes/no, and that is the whole
of the design: `Dropped`, `AlreadySent`, and `Unknown`. The reason is that
the Claude driver can only ever give the middle one. It writes a steer into
the CLI's stdin the instant it arrives — that is what makes a steer reach
the model at the next tool boundary instead of at the end of the turn, and
it was measured (see `Queue`'s doc comment) — so the line is gone before the
phone could ask for it back. What waits in `awaiting` is the *announcement*,
not the message.
Holding the write until a boundary was considered and rejected on 2026-08-31:
it would make the drop real everywhere, but it costs a steer one model call,
which is the latency the immediate write was introduced to remove. So the
refusal is the honest answer and it is reported where the reader pressed —
on the bubble itself, not in the screen's error row, which is under the
header a screen away. What a tap buys on a Claude session is therefore
knowing that the session has already been told; on a driver that really does
hold a queue (echo today) the message goes.
`Unknown` is not "we could not find out": a driver that is gone reported
everything it was holding when it closed, so there is nothing waiting.
### Session processes outlive the backend (decided 2026-08-29) ### Session processes outlive the backend (decided 2026-08-29)
A session's process is **left running when the backend stops, and adopted A session's process is **left running when the backend stops, and adopted
@@ -659,6 +688,7 @@ GET /sessions list (id, provider, host, title, model, st
POST /sessions spawn {provider, host, model, cwd, permission_mode, title} POST /sessions spawn {provider, host, model, cwd, permission_mode, title}
GET /sessions/:id/events?after=N SSE: transcript replay from N, then live GET /sessions/:id/events?after=N SSE: transcript replay from N, then live
POST /sessions/:id/message {text, attachment_ids} POST /sessions/:id/message {text, attachment_ids}
POST /sessions/:id/unqueue {message_id} take back one not read yet
POST /sessions/:id/answer {question_id, answer} (questions and permissions) POST /sessions/:id/answer {question_id, answer} (questions and permissions)
POST /sessions/:id/interrupt stop the running turn; the process stays POST /sessions/:id/interrupt stop the running turn; the process stays
POST /sessions/:id/stop end the process; the session and transcript stay POST /sessions/:id/stop end the process; the session and transcript stay
@@ -492,6 +492,23 @@ fun sendMessage(
) {} ) {}
} }
/**
* Takes back a message the session has not read yet, named by the id its `messageQueued` carried.
*
* Throws rather than returning an outcome, because both ways of failing are things the reader has
* to be told: 409 means the session was already given it, and 404 means nothing is waiting under
* that id. The bubble disappearing is the success case and it arrives on the event stream, not from
* here -- every device drops it, not only the one that tapped.
*/
fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) {
requestFromServer(
settings,
"/sessions/$sessionId/unqueue",
method = "POST",
jsonBody = JSONObject().put("messageId", messageId).toString(),
) {}
}
/** Uploads one picked image; the returned id goes into [sendMessage]. */ /** Uploads one picked image; the returned id goes into [sendMessage]. */
fun uploadAttachment( fun uploadAttachment(
settings: ServerSettings, settings: ServerSettings,
@@ -53,6 +53,16 @@ sealed class SessionEvent {
data class MessageQueued(val id: String, val text: String, val images: List<String>) : data class MessageQueued(val id: String, val text: String, val images: List<String>) :
SessionEvent() SessionEvent()
/**
* A queued message taken back before the session read it.
*
* Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects
* replays both, and without this one it would put back a bubble for a message that is never
* coming -- with nothing left to resolve it, since the [UserMessage] that normally does is
* exactly what was cancelled.
*/
data class MessageDropped(val id: String) : SessionEvent()
data class AssistantText(val delta: String) : SessionEvent() data class AssistantText(val delta: String) : SessionEvent()
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent() data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
@@ -183,6 +193,7 @@ fun parseSeqEvent(json: String): SeqEvent {
body.getString("text"), body.getString("text"),
body.stringList("images"), body.stringList("images"),
) )
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
"assistantText" -> SessionEvent.AssistantText(body.getString("delta")) "assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"toolStart" -> "toolStart" ->
SessionEvent.ToolStart( SessionEvent.ToolStart(
@@ -8,6 +8,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -371,6 +372,11 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
if (event is SessionEvent.UserMessage) { if (event is SessionEvent.UserMessage) {
queued = queued.filterNot { it.id == event.id } queued = queued.filterNot { it.id == event.id }
} }
// Waiting, then taken back. From the server rather than from the tap, so every device
// drops the bubble and a reconnect does not put back one that was cancelled.
if (event is SessionEvent.MessageDropped) {
queued = queued.filterNot { it.id == 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) {
@@ -883,6 +889,33 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
} }
} }
/**
* Asks the server to take back a message the session has not read yet.
*
* Nothing is removed here. The bubble goes on the `messageDropped` the server records, which is
* what makes the cancellation the session's own fact rather than this screen's opinion of it --
* a second device watching the same session has to lose the bubble too, and this one has to
* still lose it after a reconnect.
*
* The refusal is kept on the message it was about rather than in [actionError]: the error row
* lives under the header, and a bubble at the foot of the transcript is the thing that was
* pressed. It is the ordinary answer here rather than the exceptional one -- a Claude session
* writes a steer into the CLI the moment it arrives, so what is on screen as "waiting" is
* waiting to be *read*, not waiting to be sent.
*/
fun takeBack(messageId: String) {
scope.launch {
val refusal =
try {
withContext(Dispatchers.IO) { unqueueMessage(settings, summary.id, messageId) }
null
} catch (e: ApiException) {
e.message ?: "this message could not be taken back"
}
queued = queued.map { if (it.id == messageId) it.copy(refusal = refusal) else it }
}
}
fun act(onDone: () -> Unit = {}, action: () -> Unit) { fun act(onDone: () -> Unit = {}, action: () -> Unit) {
scope.launch { scope.launch {
try { try {
@@ -1154,6 +1187,13 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
text = waiting.text, text = waiting.text,
images = waiting.images, images = waiting.images,
pending = true, pending = true,
refusal = waiting.refusal,
// The bubble goes away on the `messageDropped` this
// produces, not here: the server is what knows whether
// the message was still its to take back, and the
// other devices watching this session have to be told
// by the same event.
onTakeBack = { takeBack(waiting.id) },
) )
} }
} }
@@ -1672,6 +1712,11 @@ private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) =
* *
* [pending] is one the server has taken and the session has not read yet -- drawn quieter, because * [pending] is one the server has taken and the session has not read yet -- drawn quieter, because
* "said" and "heard" are different claims and the transcript must not merge them. * "said" and "heard" are different claims and the transcript must not merge them.
*
* A pending bubble is tappable: [onTakeBack] asks the server to drop the message before the session
* reads it, and [refusal] is what came back when it would not. The refusal is drawn here rather
* than with the screen's other errors because this is where the reader pressed -- the error row is
* under the header, a screen away from the bubble they were looking at.
*/ */
@Composable @Composable
private fun UserBubble( private fun UserBubble(
@@ -1680,6 +1725,8 @@ private fun UserBubble(
text: String, text: String,
images: List<String> = emptyList(), images: List<String> = emptyList(),
pending: Boolean = false, pending: Boolean = false,
refusal: String? = null,
onTakeBack: (() -> Unit)? = null,
) { ) {
Box(Modifier.fillMaxWidth()) { Box(Modifier.fillMaxWidth()) {
Card( Card(
@@ -1693,7 +1740,19 @@ private fun UserBubble(
if (pending) MaterialTheme.colorScheme.surfaceVariant if (pending) MaterialTheme.colorScheme.surfaceVariant
else MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.primaryContainer
), ),
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp), modifier =
Modifier.align(Alignment.CenterEnd)
.padding(start = 48.dp)
.then(
if (onTakeBack == null) Modifier
else
Modifier.clickable(onClick = onTakeBack).semantics {
// The bubble is its own control and its own label; without this
// the only thing to read is the message, which does not say what
// pressing it does.
contentDescription = "Waiting to be read; tap to take it back"
}
),
) { ) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
// A message can be nothing but an attachment, and an empty line above a picture // A message can be nothing but an attachment, and an empty line above a picture
@@ -1713,13 +1772,32 @@ private fun UserBubble(
if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp)) if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp))
SessionImage(settings, sessionId, ref) SessionImage(settings, sessionId, ref)
} }
refusal?.let {
Spacer(Modifier.height(6.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
} }
} }
} }
} }
/** A message the server has accepted and the session has not read yet. */ /**
private data class QueuedMessage(val id: String, val text: String, val images: List<String>) * A message the server has accepted and the session has not read yet.
*
* [refusal] is why taking it back did not work, kept per message rather than on the screen: two
* bubbles can be waiting at once, and an error above them both would not say which one it was
* about.
*/
private data class QueuedMessage(
val id: String,
val text: String,
val images: List<String>,
val refusal: String? = null,
)
/** /**
* Asked before switching model, because switching is not free and the cost is invisible. * Asked before switching model, because switching is not free and the cost is invisible.
@@ -363,6 +363,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
// No row of its own: a message that is still waiting is drawn as a pending bubble below // 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. // the transcript, and becomes an ordinary one where the session read it.
is SessionEvent.MessageQueued -> items is SessionEvent.MessageQueued -> items
// The bubble goes away and nothing takes its place: the message was never read, so there
// is nothing it belongs above.
is SessionEvent.MessageDropped -> 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)
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -35,6 +36,14 @@ import androidx.compose.ui.unit.dp
* What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a * What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a
* reply, and its parse is already made by [warm] before the fold that introduces it -- so entering * reply, and its parse is already made by [warm] before the fold that introduces it -- so entering
* composition costs laying out one paragraph, not parsing a message. * composition costs laying out one paragraph, not parsing a message.
*
* The whole list sits in a [SelectionContainer], which is what makes every word in the transcript
* selectable by the platform's own press-and-hold. Here rather than at each place text is drawn: a
* transcript is one body of text to a reader, and a container per row would mean a selection could
* never cross from a reply into the tool output that follows it -- and would leave whatever was
* drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep
* their tap handlers: selection is a long press, and the container passes an ordinary click through
* to the card under it.
*/ */
@Composable @Composable
fun TranscriptList( fun TranscriptList(
@@ -45,19 +54,24 @@ fun TranscriptList(
below: @Composable () -> Unit, below: @Composable () -> Unit,
unit: @Composable (TranscriptUnit) -> Unit, unit: @Composable (TranscriptUnit) -> Unit,
) { ) {
SelectionContainer {
LazyColumn( LazyColumn(
state = state, state = state,
reverseLayout = true, reverseLayout = true,
contentPadding = TRANSCRIPT_PADDING, contentPadding = TRANSCRIPT_PADDING,
modifier = modifier =
// Timed in two halves because the frame's draw phase is where Compose's measurement // Timed in two halves because the frame's draw phase is where Compose's measurement
// lands, and "draw is high while nothing is being recorded" does not say which half; // lands, and "draw is high while nothing is being recorded" does not say which
// half;
// see [drawAccounting]. Measure includes composing the items that scrolled in. // see [drawAccounting]. Measure includes composing the items that scrolled in.
modifier modifier
.layout { measurable, constraints -> .layout { measurable, constraints ->
val started = System.nanoTime() val started = System.nanoTime()
val placeable = measurable.measure(constraints) val placeable = measurable.measure(constraints)
DebugStats.record("measure: the whole transcript", System.nanoTime() - started) DebugStats.record(
"measure: the whole transcript",
System.nanoTime() - started,
)
layout(placeable.width, placeable.height) { layout(placeable.width, placeable.height) {
val placing = System.nanoTime() val placing = System.nanoTime()
placeable.place(0, 0) placeable.place(0, 0)
@@ -81,7 +95,8 @@ fun TranscriptList(
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) } Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
} }
// Standing in for everything not fetched yet. Only here while there is more -- its // Standing in for everything not fetched yet. Only here while there is more -- its
// appearance at the top edge is also roughly when the next page is asked for, so what it // appearance at the top edge is also roughly when the next page is asked for, so what
// it
// reports is a fetch in flight rather than an end reached. // reports is a fetch in flight rather than an end reached.
if (moreHistory) { if (moreHistory) {
item(key = "history", contentType = "history") { item(key = "history", contentType = "history") {
@@ -94,6 +109,7 @@ fun TranscriptList(
} }
} }
} }
}
/** The gap between rows, and the room around the whole conversation. */ /** The gap between rows, and the room around the whole conversation. */
val TRANSCRIPT_SPACING: Dp = 8.dp val TRANSCRIPT_SPACING: Dp = 8.dp
+43 -1
View File
@@ -17,6 +17,8 @@
//! `reset` frame plus the newest window) //! `reset` frame plus the newest window)
//! POST /sessions/{id}/message {text, attachmentIds?} //! POST /sessions/{id}/message {text, attachmentIds?}
//! (starts the process first if it has exited) //! (starts the process first if it has exited)
//! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet
//! (409 when the session already has it)
//! POST /sessions/{id}/answer {questionId, answers} (questions and permissions) //! POST /sessions/{id}/answer {questionId, answers} (questions and permissions)
//! POST /sessions/{id}/interrupt stop the running turn; the process stays //! POST /sessions/{id}/interrupt stop the running turn; the process stays
//! POST /sessions/{id}/stop end the process; the session and transcript stay //! POST /sessions/{id}/stop end the process; the session and transcript stay
@@ -68,7 +70,7 @@ use tokio::sync::{broadcast, mpsc};
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tokio_stream::wrappers::{BroadcastStream, ReceiverStream}; use tokio_stream::wrappers::{BroadcastStream, ReceiverStream};
use crate::session::driver::SessionCommand; use crate::session::driver::{SessionCommand, Unqueued};
use crate::session::pending::Operation; use crate::session::pending::Operation;
use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up}; use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up};
use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec}; use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
@@ -93,6 +95,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}/events", get(events)) .route("/sessions/{id}/events", get(events))
.route("/sessions/{id}/transcript", get(transcript)) .route("/sessions/{id}/transcript", get(transcript))
.route("/sessions/{id}/message", post(message)) .route("/sessions/{id}/message", post(message))
.route("/sessions/{id}/unqueue", post(unqueue))
.route("/sessions/{id}/answer", post(answer)) .route("/sessions/{id}/answer", post(answer))
.route("/sessions/{id}/interrupt", post(interrupt)) .route("/sessions/{id}/interrupt", post(interrupt))
.route("/sessions/{id}/stop", post(stop)) .route("/sessions/{id}/stop", post(stop))
@@ -123,6 +126,10 @@ enum ApiError {
UnknownRoute, UnknownRoute,
#[error("{0}")] #[error("{0}")]
BadRequest(String), BadRequest(String),
/// The request was understood and the state it names has moved on --
/// distinct from `BadRequest`, which is a caller that got it wrong.
#[error("{0}")]
Conflict(String),
#[error(transparent)] #[error(transparent)]
Internal(#[from] anyhow::Error), Internal(#[from] anyhow::Error),
} }
@@ -132,6 +139,7 @@ impl IntoResponse for ApiError {
let status = match self { let status = match self {
Self::NotFound(_) | Self::UnknownRoute => StatusCode::NOT_FOUND, Self::NotFound(_) | Self::UnknownRoute => StatusCode::NOT_FOUND,
Self::BadRequest(_) => StatusCode::BAD_REQUEST, Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::Conflict(_) => StatusCode::CONFLICT,
Self::Internal(err) => { Self::Internal(err) => {
// The only variant whose real cause isn't safe to hand // The only variant whose real cause isn't safe to hand
// back verbatim, and the only one worth a log line. // back verbatim, and the only one worth a log line.
@@ -900,6 +908,40 @@ async fn message(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct UnqueueRequest {
/// The id the `messageQueued` event carried, which is what the bubble
/// on screen is drawn from.
message_id: String,
}
/// Takes back a message the session has not read yet.
///
/// The two failures are separate answers rather than one refusal, because
/// they are different things to whoever tapped: `409` means the session has
/// already been told and the message is on its way into the conversation,
/// and `404` means nothing is waiting under that id -- a bubble on screen
/// that something else has already resolved. See [`Driver::unqueue`]; the
/// Claude driver can only ever give the first, since it writes a steer into
/// the CLI the moment it arrives.
async fn unqueue(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<UnqueueRequest>,
) -> Result<StatusCode, ApiError> {
match lookup(&manager, &id)?.unqueue(&body.message_id) {
Unqueued::Dropped => Ok(StatusCode::NO_CONTENT),
Unqueued::AlreadySent => Err(ApiError::Conflict(
"the session has already been given this message".to_string(),
)),
Unqueued::Unknown => Err(ApiError::NotFound(
"this message is not waiting to be read".to_string(),
)),
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
+19 -1
View File
@@ -57,7 +57,7 @@ use serde_json::{Value, json};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus}; use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus, Unqueued};
use super::process; use super::process;
use super::transport::{Launch, Streams, Transport}; use super::transport::{Launch, Streams, Transport};
use crate::config::{ProviderConfig, SessionConfig}; use crate::config::{ProviderConfig, SessionConfig};
@@ -595,6 +595,24 @@ impl Driver for ClaudeDriver {
self.send_line(line); self.send_line(line);
} }
/// Never droppable, and that is a property of the design rather than
/// an omission.
///
/// A message queued here has already been written to the CLI's stdin
/// -- see [`Queue`], where only the *announcement* waits -- because
/// that is what makes a steer reach the model at the next tool
/// boundary instead of at the end of the turn. A line in the fifo
/// cannot be recalled, so the only honest answers are "the session has
/// already been told" and "nothing is waiting under that id".
fn unqueue(&self, id: &str) -> Unqueued {
let queue = self.queue.lock().unwrap();
if queue.awaiting.iter().any(|(waiting, ..)| waiting == id) {
Unqueued::AlreadySent
} else {
Unqueued::Unknown
}
}
fn answer_question(&self, id: &str, answers: &[String]) { fn answer_question(&self, id: &str, answers: &[String]) {
let response = { let response = {
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
+48
View File
@@ -114,6 +114,22 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
images: Vec<ImageRef>, images: Vec<ImageRef>,
}, },
/// A message taken out of the queue before the session read it, by
/// somebody tapping the bubble that was waiting for it.
///
/// Recorded for the same reason `MessageQueued` is: the queue is the
/// server's, so what is waiting has to be answerable from the
/// transcript alone. Without it a phone that reconnects replays the
/// `MessageQueued` and puts back a bubble for a message that will
/// never arrive -- and nothing later would ever resolve it, since the
/// `UserMessage` that normally does is exactly what is not coming.
///
/// Only ever sent for a message that had not been handed over. One
/// that has is not droppable and says so instead; see
/// [`Unqueued::AlreadySent`].
MessageDropped {
id: 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
/// it. The manager turns this into the `UserMessage` above, so it /// it. The manager turns this into the `UserMessage` above, so it
/// never reaches a phone itself. /// never reaches a phone itself.
@@ -444,6 +460,25 @@ pub enum SessionStatus {
Unknown, Unknown,
} }
/// What became of a request to take a queued message back.
///
/// Three states rather than a bool because the two failures are not the
/// same fact. A driver that writes into its session the moment a message
/// arrives -- which is what `ClaudeDriver` does, so that a steer reaches
/// the model at the next tool boundary rather than at the end of the turn
/// -- can never take one back, and a phone that was told only "no" would
/// have to guess whether it had asked too late or asked about nothing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Unqueued {
/// Out of the queue; the session will never read it.
Dropped,
/// Already handed to the session, so there is nothing left to take
/// back. The message is on its way into the conversation.
AlreadySent,
/// Nothing is waiting under that id.
Unknown,
}
/// Where a driver reports events. Unbounded because producers are child /// Where a driver reports events. Unbounded because producers are child
/// processes a slow phone must never be able to stall; the transcript file /// processes a slow phone must never be able to stall; the transcript file
/// is the backpressure-free buffer of record. /// is the backpressure-free buffer of record.
@@ -463,6 +498,19 @@ pub trait Driver: Send + Sync {
/// message in the transcript, so a driver that never sends it drops /// message in the transcript, so a driver that never sends it drops
/// the message from the conversation entirely. /// the message from the conversation entirely.
fn send_user_message(&self, text: String, images: Vec<ImageRef>); fn send_user_message(&self, text: String, images: Vec<ImageRef>);
/// Takes back a message that is still waiting, named by the id its
/// [`Event::MessageQueued`] carried.
///
/// Answering is the whole of the contract: a driver that drops the
/// message owes an [`Event::MessageDropped`], and one that cannot must
/// say which of the two reasons it is, because they are different
/// things to a reader -- "the session has already been told" is worth
/// knowing, and "there is nothing under that id" means the bubble on
/// screen is stale. The default is the honest answer for a driver with
/// no queue at all: nothing of yours is waiting.
fn unqueue(&self, _id: &str) -> Unqueued {
Unqueued::Unknown
}
/// Answers one question with everything that was chosen, in the order /// Answers one question with everything that was chosen, in the order
/// it was offered. One answer is a list of one; a driver whose dialect /// it was offered. One answer is a list of one; a driver whose dialect
/// takes a single value joins them where it writes it. /// takes a single value joins them where it writes it.
+18 -1
View File
@@ -53,7 +53,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use super::driver::{Driver, Event, EventSink, ImageRef, QuestionOption, SessionStatus}; use super::driver::{Driver, Event, EventSink, ImageRef, QuestionOption, SessionStatus, Unqueued};
/// Delay between streamed deltas -- long enough that streaming is visibly /// Delay between streamed deltas -- long enough that streaming is visibly
/// streaming in the UI, short enough that tests waiting on a full turn /// streaming in the UI, short enough that tests waiting on a full turn
@@ -796,6 +796,23 @@ impl Driver for EchoDriver {
!self.busy.load(Ordering::SeqCst) !self.busy.load(Ordering::SeqCst)
} }
/// Really droppable, which is what makes this the rig for the phone's
/// side of it: the held message is this driver's own and nothing has
/// been written anywhere, so a tap here exercises the whole path
/// through to the bubble disappearing on every device. The Claude
/// driver can only ever refuse -- see its own `unqueue` -- so it
/// cannot exercise the case where the drop succeeds.
fn unqueue(&self, id: &str) -> Unqueued {
let mut queued = self.queued.lock().unwrap();
let Some(at) = queued.iter().position(|(waiting, ..)| waiting == id) else {
return Unqueued::Unknown;
};
queued.remove(at);
drop(queued);
self.emit(Event::MessageDropped { id: id.to_string() });
Unqueued::Dropped
}
fn send_user_message(&self, text: String, images: Vec<ImageRef>) { fn send_user_message(&self, text: String, images: Vec<ImageRef>) {
// Announced, because this is a message: every driver owes exactly // Announced, because this is a message: every driver owes exactly
// one `MessageTaken` per message, and one that quietly vanishes // one `MessageTaken` per message, and one that quietly vanishes
+98 -1
View File
@@ -32,7 +32,9 @@ use crate::config::{
Config, DriverKind, ProviderConfig, SessionConfig, SetupConfig, SshConfig, TokenEntry, Config, DriverKind, ProviderConfig, SessionConfig, SetupConfig, SshConfig, TokenEntry,
}; };
use claude::ClaudeDriver; use claude::ClaudeDriver;
use driver::{Driver, Event, EventSink, ImageRef, SessionCommand, SessionStatus, context_after}; use driver::{
Driver, Event, EventSink, ImageRef, SessionCommand, SessionStatus, Unqueued, context_after,
};
use echo::EchoDriver; use echo::EchoDriver;
use llama::LlamaDriver; use llama::LlamaDriver;
use transcript::{SeqEvent, Transcript}; use transcript::{SeqEvent, Transcript};
@@ -440,6 +442,21 @@ impl LiveSession {
self.ask("be interrupted", |driver| driver.interrupt()); self.ask("be interrupted", |driver| driver.interrupt());
} }
/// Takes back a message the session has not read yet, named by the id
/// its `MessageQueued` carried. See [`Driver::unqueue`] for why the
/// answer has three states.
///
/// A session with no process answers `Unknown` rather than being
/// reported as a failure, and that is the true answer: a driver on its
/// way out already said what it was holding (`Queue::close`), so there
/// is nothing waiting to take back.
pub fn unqueue(&self, message_id: &str) -> Unqueued {
match self.driver() {
Some(driver) => driver.unqueue(message_id),
None => Unqueued::Unknown,
}
}
/// Leaves this session's process running and stops attending to it, /// Leaves this session's process running and stops attending to it,
/// for a server that is going away and means to come back. See /// for a server that is going away and means to come back. See
/// [`Driver::detach`]. /// [`Driver::detach`].
@@ -2716,6 +2733,86 @@ mod tests {
); );
} }
/// A queued message can be taken back until the driver has handed it
/// over, and the taking back is an event rather than a return value --
/// which is what makes the bubble disappear on every device watching,
/// and stay gone when one of them reconnects and replays.
///
/// Exercised on echo because echo really holds its queue. The Claude
/// driver writes a steer into the CLI the moment it arrives, so it can
/// only ever answer `AlreadySent`; the case where a drop *succeeds*
/// has no other driver to be tested against.
#[tokio::test]
async fn a_queued_message_can_be_taken_back_until_the_session_has_it() {
let dir = tempfile::tempdir().expect("tempdir");
seed_echo_only(&dir.path().join("config.ron"));
let manager = SessionManager::new(
dir.path().join("config.ron"),
dir.path().join("sessions"),
dir.path().join("models"),
)
.expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
let session = manager.session(&info.id).expect("live session");
let mut rx = session.subscribe();
// A turn long enough that the next message has to wait behind it.
session.send_message("/slow 1".to_string(), Vec::new());
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Running
}
)
})
.await;
session.send_message("second thoughts".to_string(), Vec::new());
let seen = collect_until(&mut rx, |event| {
matches!(event, Event::MessageQueued { .. })
})
.await;
let Some(Event::MessageQueued { id, .. }) =
seen.iter().map(|entry| entry.event.clone()).next_back()
else {
panic!("expected the message to be queued: {seen:?}");
};
assert_eq!(session.unqueue(&id), Unqueued::Dropped);
let seen = collect_until(&mut rx, |event| {
matches!(event, Event::MessageDropped { .. })
})
.await;
assert!(
seen.iter().any(|entry| matches!(
&entry.event,
Event::MessageDropped { id: dropped } if *dropped == id
)),
"the drop has to be recorded, not merely returned: {seen:?}"
);
// Gone for good: the turn ends without the message ever entering
// the conversation, and asking again says there is nothing there
// rather than dropping it twice.
assert_eq!(session.unqueue(&id), Unqueued::Unknown);
let seen = collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Idle
}
)
})
.await;
assert!(
!seen
.iter()
.any(|entry| matches!(entry.event, Event::UserMessage { .. })),
"a message taken back must never be read: {seen:?}"
);
}
#[tokio::test] #[tokio::test]
async fn a_command_on_an_idle_session_goes_straight_out() { async fn a_command_on_an_idle_session_goes_straight_out() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");