diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 3448629..e3c48d1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -561,6 +561,23 @@ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: ) {} } +/** + * Asks the session to run one of its own commands. + * + * Sent as typed. The server turns the two it understands into its own operations -- a compaction, a + * rename, which is also what the settings screen sends -- and passes anything else to whatever runs + * the session. Either way it waits for the turn to end if one is in flight, and says so on the + * event stream, which is where the waiting bubble comes from. + */ +fun runCommand(settings: ServerSettings, sessionId: String, text: String) { + requestFromServer( + settings, + "/sessions/$sessionId/command", + method = "POST", + jsonBody = JSONObject().put("text", text).toString(), + ) {} +} + /** * Asks the session to summarise its own history and carry on from the summary. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt new file mode 100644 index 0000000..82ac0de --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt @@ -0,0 +1,148 @@ +package com.example.aiapp + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Something a session can be asked to do to itself, rather than something to say to it. + * + * These are the two this app understands, and understanding them is what lets it show them: a + * suggestion while one is being typed, a name in the settings screen that sends one, and a bubble + * that stays up while the session is too busy to run it. Anything else beginning with "/" is passed + * through to whatever runs the session, because a dialect's own vocabulary is its own and grows + * without this list -- it just arrives unannounced and unexplained. + */ +data class SessionCommand( + /** With the slash, as it is typed and as it is sent. */ + val name: String, + /** One line, in the suggestion list: what it does, not how. */ + val summary: String, + /** What follows the name, named for the reader, or null when nothing does. */ + val argument: String?, +) { + /** What to put in the box when this is picked: ready to send, or ready to be finished. */ + fun typed(): String = if (argument == null) name else "$name " +} + +val SESSION_COMMANDS = + listOf( + SessionCommand( + "/compact", + "Summarise the conversation so far and carry on from the summary", + null, + ), + SessionCommand("/rename", "Change what this session is called", "name"), + ) + +/** + * The commands worth offering for what has been typed so far. + * + * Only for a line that starts with a slash and has not yet become a whole command with an argument + * -- once there is something after "/rename ", the reader is writing the name and a list of + * commands underneath it is in the way. + */ +fun suggestedCommands(input: String): List { + if (!input.startsWith("/") || input.contains(' ')) return emptyList() + return SESSION_COMMANDS.filter { it.name.startsWith(input) } +} + +/** + * The commands matching what is being typed, above the box they are being typed into. + * + * Above rather than over: a list that covers the transcript hides what the command is about, and + * the reader is usually looking at the thing they mean to act on. + */ +@Composable +fun CommandSuggestions( + commands: List, + onPick: (SessionCommand) -> Unit, + modifier: Modifier = Modifier, +) { + if (commands.isEmpty()) return + Card(modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Column(Modifier.padding(vertical = 4.dp)) { + commands.forEach { command -> + Row( + Modifier.fillMaxWidth() + .clickable { onPick(command) } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + // The command in the colour commands are, so the suggestion and the + // bubble it becomes are visibly the same thing. + if (command.argument == null) command.name + else "${command.name} <${command.argument}>", + style = MaterialTheme.typography.titleSmall, + color = commandColor, + ) + Spacer(Modifier.width(12.dp)) + Text( + command.summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +/** + * A command, where the reader put it: at their end of the conversation. + * + * Blue rather than the colour of something they said, because they did not say it to the model -- + * it is an instruction to the session, and the reply to it is the session changing rather than + * anything appearing here. + * + * [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a + * reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes + * and reads as having been missed. + */ +@Composable +fun CommandBubble(text: String, waiting: Boolean = false) { + Box(Modifier.fillMaxWidth()) { + Card( + colors = CardDefaults.cardColors(containerColor = commandColor), + modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp), + ) { + Column(Modifier.padding(12.dp)) { + // Stated beside the fill rather than inherited: a semantic colour has to carry + // its own contrast, because the surface under it will not change to rescue it. + Text(text, color = MaterialTheme.colorScheme.inverseOnSurface) + if (waiting) { + Spacer(Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.width(12.dp).height(12.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.inverseOnSurface, + ) + Spacer(Modifier.width(6.dp)) + Text( + "waiting for this turn to end", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.inverseOnSurface, + ) + } + } + } + } + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt index 56065fa..6f445cc 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt @@ -76,11 +76,11 @@ fun CompactingRow(seconds: Long?, modifier: Modifier = Modifier) { style = MaterialTheme.typography.bodySmall, // The colour is stated beside the fill rather than inherited: a semantic colour has // to carry its own contrast, since the surface under it will not change to rescue it. - color = compactingColor, + color = commandColor, ) LinearProgressIndicator( modifier = Modifier.fillMaxWidth().padding(top = 6.dp), - color = compactingColor, + color = commandColor, trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt index 9f12332..92b6d9e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt @@ -60,6 +60,17 @@ sealed class SessionEvent { */ data class PeerMessage(val from: String, val text: String) : SessionEvent() + /** + * A command the session was asked to run on itself and cannot run yet. + * + * Resolved by [CommandSent] with the same id. A command that ran straight away has only that + * one, so nothing here ever draws a bubble that resolves in the same frame. + */ + data class CommandQueued(val id: String, val text: String) : SessionEvent() + + /** The same command, handed to the session. */ + data class CommandSent(val id: String, val text: String) : SessionEvent() + data class Status(val state: String) : SessionEvent() /** @@ -144,6 +155,9 @@ fun parseSeqEvent(json: String): SeqEvent { ) "peerMessage" -> SessionEvent.PeerMessage(body.getString("from"), body.getString("text")) + "commandQueued" -> + SessionEvent.CommandQueued(body.getString("id"), body.getString("text")) + "commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text")) "status" -> SessionEvent.Status(body.getString("state")) "settings" -> SessionEvent.Settings( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index 732633a..3fd93cd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -324,7 +324,7 @@ fun StatusText(status: String) { when (status) { "awaitingInput" -> "your turn" to awaitingColor "running" -> "running" to runningColor - "compacting" -> "compacting" to compactingColor + "compacting" -> "compacting" to commandColor "exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant // Said in words, because it differs in kind from the others rather than in degree: // the session is not idle and has not exited, nobody has been able to find out diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 071d769..f207dc5 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -143,6 +143,14 @@ sealed class TranscriptItem { data class PeerNote(override val seq: Long, val from: String, val text: String) : TranscriptItem() + /** + * A command the session ran on itself -- `/compact`, `/rename`. + * + * Kept in the transcript rather than only shown while it waits, because it explains what + * follows: a conversation that suddenly has half the context, or a session with a new name. + */ + data class CommandRow(override val seq: Long, val text: String) : TranscriptItem() + /** Placeholder row for events this build can't render (newer kinds). */ data class Note(override val seq: Long, val text: String) : TranscriptItem() @@ -250,7 +258,9 @@ fun foldEvent(items: List, entry: SeqEvent): List items + TranscriptItem.PeerNote(entry.seq, event.from, event.text) + is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text) // Screen-level state, not transcript rows -- see SessionScreen. + is SessionEvent.CommandQueued -> items is SessionEvent.Settings -> items is SessionEvent.Status -> items is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message) @@ -320,6 +330,11 @@ fun SessionScreen( var pendingAttachments by remember { mutableStateOf(listOf()) } // What this session is set to now, seeded from the row that opened it and // then owned here, because changing either is something this screen does. + // The name shown at the top. Held here rather than read from the row that opened this + // screen, because renaming is something this screen can do -- through the settings below it, + // or by typing the command -- and a header still showing the old name reads as a rename that + // did not take. + var title by remember(summary.id) { mutableStateOf(summary.title) } var model by remember { mutableStateOf(summary.model) } var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") } // The models this provider actually offers, asked of the server rather @@ -344,6 +359,10 @@ fun SessionScreen( // reading of events: after everything taken in, not yet taken in // itself. var queued by remember { mutableStateOf(listOf()) } + // 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. + var waitingCommands by remember { mutableStateOf(listOf>()) } val running = status == "running" || status == "compacting" var moreHistory by remember { mutableStateOf(true) } var loadingHistory by remember { mutableStateOf(false) } @@ -393,6 +412,14 @@ fun SessionScreen( // earlier one -- and only the first match, so two // identical messages wait twice. if (event is SessionEvent.UserMessage) queued = queued - event.text + // 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) { + waitingCommands = waitingCommands + (event.id to event.text) + } + if (event is SessionEvent.CommandSent) { + waitingCommands = waitingCommands.filterNot { it.first == event.id } + } // Kept as well as folded. Folding is one-way -- a tool's // start and end become one row -- so a page arriving in // front of what is already here cannot be stitched on @@ -624,6 +651,25 @@ fun SessionScreen( val text = input.trim() val attachments = pendingAttachments if (text.isEmpty() && attachments.isEmpty()) return + // A command is not a message: it is an instruction to the session about itself, and one + // written into a running turn is read by the model instead. The server holds it until the + // turn ends and says so, which is where its waiting bubble comes from -- so nothing is + // held here, and there is no local guess to correct when the answer arrives. + if (text.startsWith("/") && attachments.isEmpty()) { + input = "" + // The one command with a visible effect outside the transcript, applied when the + // server has accepted it rather than when it was typed: the name is this app's own + // datum and changes at once, and only telling the session waits for a boundary. + val renamed = + text.removePrefix("/rename ").trim().takeIf { + text.startsWith("/rename ") && it.isNotEmpty() + } + act { + runCommand(settings, summary.id, text) + renamed?.let { title = it } + } + return + } input = "" pendingAttachments = emptyList() if (running && text.isNotEmpty()) queued = queued + text @@ -672,7 +718,7 @@ fun SessionScreen( ) { TextButton(onClick = onBack) { Text("Back") } Column(Modifier.weight(1f)) { - Text(summary.title, style = MaterialTheme.typography.titleMedium) + Text(title, style = MaterialTheme.typography.titleMedium) Text( listOfNotNull( summary.provider, @@ -739,9 +785,12 @@ fun SessionScreen( // Below the working indicator, because that is where they // are in the session's reading of events: after everything // it has taken in, and not yet taken in themselves. - if (queued.isNotEmpty()) { + if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) { item(key = "queued") { Column(horizontalAlignment = Alignment.End) { + waitingCommands.forEach { (_, text) -> + CommandBubble(text, waiting = true) + } queued.forEach { text -> UserBubble(text, pending = true) } } } @@ -867,6 +916,7 @@ fun SessionScreen( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + is TranscriptItem.CommandRow -> CommandBubble(item.text) is TranscriptItem.CompactedNote -> CompactedRow(item) is TranscriptItem.PeerNote -> PeerMessageRow( @@ -913,6 +963,13 @@ fun SessionScreen( } } + // Between the transcript and the box: above what is being typed, so the list does not + // cover the thing the command is about, and below everything that explains it. + CommandSuggestions( + commands = suggestedCommands(input), + onPick = { command -> input = command.typed() }, + ) + // Always enabled -- a send while the session is running becomes a // steering message injected at the next tool boundary, which is // the point of the whole app. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt index 2fec588..452c444 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -110,15 +110,15 @@ val failedColor: Color @Composable get() = MaterialTheme.colorScheme.error /** - * Working on the conversation rather than in it: a compaction. + * About the session rather than about the task: a command, and the compaction one of them starts. * - * Its own colour because it is its own kind of busy. Everything else a session does is progress - * through the task; this is the session rewriting what it remembers, it can take minutes, and - * nothing it produces appears in the transcript until it is over. A reader who has learned that - * blue means "not stuck, but not answering you either" has learned the only thing that - * distinguishes it from a session that has hung. + * Its own colour because it is its own kind of work. Everything else a session does is progress + * through what was asked of it; this is the session acting on itself -- rewriting what it + * remembers, taking a new name -- and none of it appears in the transcript as an answer to + * anything. A reader who has learned that blue means "not stuck, but not replying to you either" + * has learned the thing that distinguishes it from a session that has hung. */ -val compactingColor: Color +val commandColor: Color @Composable get() = Mocha.Blue /** Waiting on a person: a question, a permission, a turn that is theirs. */ diff --git a/server/src/routes.rs b/server/src/routes.rs index 276e943..6b53e2d 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -19,6 +19,7 @@ //! POST /sessions/{id}/interrupt //! POST /sessions/{id}/title {title} //! POST /sessions/{id}/model {model} +//! POST /sessions/{id}/command {text} -- /compact, /rename x, or the dialect's own //! POST /sessions/{id}/compact //! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message //! GET /sessions/{id}/files/{name} images the session produced or was sent @@ -58,6 +59,7 @@ use tokio::sync::{broadcast, mpsc}; use tokio_stream::StreamExt; use tokio_stream::wrappers::ReceiverStream; +use crate::session::driver::SessionCommand; use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up}; use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec}; @@ -85,6 +87,7 @@ pub fn router(manager: Arc) -> Router { .route("/sessions/{id}/model", post(set_model)) .route("/sessions/{id}/permission-mode", post(set_permission_mode)) .route("/sessions/{id}/compact", post(compact)) + .route("/sessions/{id}/command", post(command)) .route("/sessions/{id}/attachments", post(upload_attachment)) .route("/sessions/{id}/files/{name}", get(serve_file)) // Phone photos overflow axum's 2 MB default body cap. @@ -737,6 +740,39 @@ async fn set_permission_mode( Ok(StatusCode::NO_CONTENT) } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CommandRequest { + text: String, +} + +/// Runs one of the session's own commands, now or at the next boundary. +/// +/// The two this server understands are turned into the operations it has +/// -- a compaction, a rename, which is also how the settings screen asks +/// -- and everything else is passed to the session verbatim, because a +/// dialect's vocabulary is its own and grows without this file. +async fn command( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + let text = body.text.trim(); + let (name, rest) = match text.split_once(char::is_whitespace) { + Some((name, rest)) => (name, rest.trim()), + None => (text, ""), + }; + match (name, rest) { + ("/compact", _) => lookup(&manager, &id)?.run_command(SessionCommand::Compact), + // Through the manager, not the session: a name is persisted and + // listed as well as forwarded, and that is one operation. + ("/rename", "") => return Err(bad_request(anyhow::anyhow!("a session needs a name"))), + ("/rename", title) => manager.rename_session(&id, title).map_err(bad_request)?, + _ => lookup(&manager, &id)?.run_command(SessionCommand::Raw(text.to_string())), + } + Ok(StatusCode::NO_CONTENT) +} + async fn compact( State(manager): State>, UrlPath(id): UrlPath, diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 680a814..b118d01 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -398,6 +398,29 @@ impl ClaudeDriver { let _ = self.to_child.send(line); } + /// Writes one of the CLI's own commands into the session. + /// + /// Slash commands ride the normal user-message channel -- there is no + /// control request for them; `set_session_name` is not a subtype the + /// CLI knows, measured by asking. The turn they start is marked here + /// because they produce a `result` like any other, so a message sent + /// meanwhile belongs in the queue's "written, announce when read" + /// path rather than being reported as read the moment it is typed. + /// + /// Nothing is emitted about the command itself: the manager has + /// already said it was sent, and the CLI announces what it does -- + /// saying so here would be this side's guess standing in for its + /// measurement. + fn local_command(&self, text: String) { + self.queue.lock().unwrap().running = true; + self.send_line( + json!({"type": "user", "message": {"role": "user", "content": [ + {"type": "text", "text": text} + ]}}) + .to_string(), + ); + } + /// Sends a control request, remembering what it asked for. /// /// `confirms` is the setting this request will have made if the CLI @@ -516,6 +539,15 @@ impl Driver for ClaudeDriver { ); } + fn run_command(&self, text: &str) { + // Whatever the CLI's own vocabulary holds -- `/context`, `/usage`, + // a command added after this was written. It rides the same + // channel as `/compact` and `/rename` and starts a turn the same + // way, so the same bookkeeping applies; what it means is the + // CLI's business, not this file's. + self.local_command(text.to_string()); + } + fn set_title(&self, title: &str) { // The CLI's own mechanism, and a local command rather than a // control request -- `set_session_name` is not a subtype it @@ -530,32 +562,11 @@ impl Driver for ClaudeDriver { }); return; } - self.queue.lock().unwrap().running = true; - self.send_line( - json!({"type": "user", "message": {"role": "user", "content": [ - {"type": "text", "text": format!("/rename {title}")} - ]}}) - .to_string(), - ); + self.local_command(format!("/rename {title}")); } fn compact(&self) { - // A turn is in flight from here. The CLI answers `/compact` like - // any other message -- a status line, a boundary, then a `result` - // -- so a message sent meanwhile belongs in the queue's "written, - // announce when it has been read" path rather than being reported - // as read the moment it is typed. - self.queue.lock().unwrap().running = true; - // Slash commands ride the normal user-message channel. Nothing is - // emitted here: the CLI announces the compaction itself, and - // saying so first would be this side's guess standing in for its - // measurement. - self.send_line( - json!({"type": "user", "message": {"role": "user", "content": [ - {"type": "text", "text": "/compact"} - ]}}) - .to_string(), - ); + self.local_command("/compact".to_string()); } fn detach(&self) { diff --git a/server/src/session/driver.rs b/server/src/session/driver.rs index 66b63aa..402de1c 100644 --- a/server/src/session/driver.rs +++ b/server/src/session/driver.rs @@ -223,11 +223,68 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] trigger: Option, }, + /// A command the session was asked to run on itself, held because it + /// cannot run yet. + /// + /// These are not messages: `/compact` and `/rename` are instructions + /// to the session about itself, and a session in the middle of a turn + /// reads a line written to it as something the model should see. So + /// they wait for the turn to end, and this is what a phone draws + /// while they do -- otherwise pressing Compact during a long turn + /// does nothing visible for minutes and looks like it was missed. + CommandQueued { + id: String, + /// What to show for it: the command as a person would type it. + text: String, + }, + /// The same command, now handed to the session. Its [`CommandQueued`] + /// stops being pending when this arrives, matched by `id`; a command + /// that ran immediately has only this. + CommandSent { + id: String, + text: String, + }, Error { message: String, }, } +/// Something a session can be asked to do to itself. +/// +/// A closed set rather than a string, because the two that are not +/// dialect-specific have to reach every provider: compaction is a +/// capability an llama session may one day have, and a name is this +/// server's own. `Raw` is the escape for a dialect's own commands -- +/// `/context`, `/usage` -- which only the thing running the session can +/// interpret. +#[derive(Debug, Clone, PartialEq)] +pub enum SessionCommand { + Compact, + SetTitle(String), + Raw(String), +} + +impl SessionCommand { + /// What a person would have typed to ask for this, which is what a + /// phone shows while it waits. + pub fn label(&self) -> String { + match self { + Self::Compact => "/compact".to_string(), + Self::SetTitle(title) => format!("/rename {title}"), + Self::Raw(text) => text.clone(), + } + } + + /// Runs it. Called only at a boundary -- see [`Event::CommandQueued`]. + pub fn apply(&self, driver: &dyn Driver) { + match self { + Self::Compact => driver.compact(), + Self::SetTitle(title) => driver.set_title(title), + Self::Raw(text) => driver.run_command(text), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SessionStatus { @@ -300,6 +357,18 @@ pub trait Driver: Send + Sync { /// `/rename` afterwards, which is what puts the same name in its own /// session picker and in what other agents see. fn set_title(&self, title: &str); + /// Runs a command this session's own dialect understands, verbatim. + /// + /// For the ones this app has no opinion about -- `/context`, `/usage`, + /// anything a CLI adds next month. A driver whose process has no such + /// vocabulary says so with an [`Event::Error`] rather than sending it + /// as a message, which would put a line meant for the session in front + /// of the model instead. + /// + /// Like [`Driver::compact`] and [`Driver::set_title`], this is called + /// only when the session is between turns; the waiting is done above, + /// once, for every driver. + fn run_command(&self, text: &str); /// pi: native compaction; claude: `/compact`. fn compact(&self); /// Stop attending to the process but leave it running, because this diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index 669c515..85a22d5 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -192,51 +192,14 @@ impl EchoDriver { }); } - pub fn new(sink: EventSink) -> Self { - let driver = Self { - sink, - pending_questions: Mutex::new(Vec::new()), - busy: Arc::new(AtomicBool::new(false)), - queued: Arc::new(Mutex::new(Vec::new())), - }; - driver.emit(Event::Status { - state: SessionStatus::Idle, - }); - driver - } - - /// Sends are infallible from the driver's point of view: a closed sink - /// means the session is being torn down, and there is nobody left to - /// report to. - fn emit(&self, event: Event) { - let _ = self.sink.send(event); - } -} - -/// Ending a turn is also when anything held during it is taken up -- the -/// moment a real CLI would have injected it. One place, because a turn has -/// several ways to end (a reply, an interrupt, a compaction) and every one -/// of them owes the same answer. -fn finish_turn(sink: &EventSink, queued: &Mutex>, busy: &AtomicBool) { - let held = std::mem::take(&mut *queued.lock().unwrap()); - for text in held { - // Announced before it is answered, in that order: a phone showing - // the message as pending needs the signal that it has been read, - // and the answer is meaningless above a message still drawn as - // waiting. - let _ = sink.send(Event::MessageTaken { text: text.clone() }); - let _ = sink.send(Event::AssistantText { - delta: format!("\n(taken from the queue) You said: {text}"), - }); - } - busy.store(false, Ordering::SeqCst); - let _ = sink.send(Event::Status { - state: SessionStatus::Idle, - }); -} - -impl Driver for EchoDriver { - fn send_user_message(&self, text: String, _images: Vec) { + /// One typed line, whether it arrived as a message or as a command. + /// + /// `announce` is the difference and it is the whole of it: a message + /// is announced with `MessageTaken`, which is what puts it in the + /// transcript, and a command is not -- the manager has already + /// recorded that one was sent, and saying so twice drew the same + /// line in both colours. + fn handle(&self, text: String, _images: Vec, announce: bool) { let sink = self.sink.clone(); // Mid-turn messages are held rather than answered, the way a real @@ -255,7 +218,9 @@ impl Driver for EchoDriver { // `MessageTaken` per message, and a command that quietly vanishes // from the transcript is the one thing echo must not model. if let Some(rest) = text.strip_prefix("/peer") { - self.emit(Event::MessageTaken { text: text.clone() }); + if announce { + self.emit(Event::MessageTaken { text: text.clone() }); + } self.emit(Event::PeerMessage { from: "dev-updater-f5".to_string(), text: if rest.trim().is_empty() { @@ -274,13 +239,17 @@ impl Driver for EchoDriver { // way. `Driver::compact` is what the manager's own route calls; // this is the typed path onto it. if text.trim() == "/compact" { - self.emit(Event::MessageTaken { text }); + if announce { + self.emit(Event::MessageTaken { text }); + } self.compact(); return; } if text.trim() == "/ask" { - self.emit(Event::MessageTaken { text }); + if announce { + self.emit(Event::MessageTaken { text }); + } self.ask_user_question(); return; } @@ -352,7 +321,9 @@ impl Driver for EchoDriver { // anyway: a driver that skips this leaves the phone holding a // message it thinks is still queued, and the point of an echo // provider is that it behaves like the real ones. - send(Event::MessageTaken { text: text.clone() }); + if announce { + send(Event::MessageTaken { text: text.clone() }); + } send(Event::Status { state: SessionStatus::Running, }); @@ -442,6 +413,67 @@ impl Driver for EchoDriver { }); } + pub fn new(sink: EventSink) -> Self { + let driver = Self { + sink, + pending_questions: Mutex::new(Vec::new()), + busy: Arc::new(AtomicBool::new(false)), + queued: Arc::new(Mutex::new(Vec::new())), + }; + driver.emit(Event::Status { + state: SessionStatus::Idle, + }); + driver + } + + /// Sends are infallible from the driver's point of view: a closed sink + /// means the session is being torn down, and there is nobody left to + /// report to. + fn emit(&self, event: Event) { + let _ = self.sink.send(event); + } +} + +/// Ending a turn is also when anything held during it is taken up -- the +/// moment a real CLI would have injected it. One place, because a turn has +/// several ways to end (a reply, an interrupt, a compaction) and every one +/// of them owes the same answer. +fn finish_turn(sink: &EventSink, queued: &Mutex>, busy: &AtomicBool) { + let held = std::mem::take(&mut *queued.lock().unwrap()); + for text in held { + // Announced before it is answered, in that order: a phone showing + // the message as pending needs the signal that it has been read, + // and the answer is meaningless above a message still drawn as + // waiting. + let _ = sink.send(Event::MessageTaken { text: text.clone() }); + let _ = sink.send(Event::AssistantText { + delta: format!("\n(taken from the queue) You said: {text}"), + }); + } + busy.store(false, Ordering::SeqCst); + let _ = sink.send(Event::Status { + state: SessionStatus::Idle, + }); +} + +impl Driver for EchoDriver { + fn send_user_message(&self, text: String, images: Vec) { + // Announced, because this is a message: every driver owes exactly + // one `MessageTaken` per message, and one that quietly vanishes + // from the transcript is the thing echo must not model. A command + // owes none -- the manager has already recorded that it was sent, + // and announcing it again drew the same line twice, once in each + // colour. + self.handle(text, images, true); + } + + /// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` -- + /// so this is the same path with the same parsing, and the fixture + /// behaves like a real session driven the same way. + fn run_command(&self, text: &str) { + self.handle(text.to_string(), Vec::new(), false); + } + fn answer_question(&self, id: &str, answers: &[String]) { let answer = answers.join(", "); let (answered, waiting) = { diff --git a/server/src/session/llama.rs b/server/src/session/llama.rs index a969256..a0a3543 100644 --- a/server/src/session/llama.rs +++ b/server/src/session/llama.rs @@ -401,6 +401,14 @@ impl Driver for LlamaDriver { }); } + fn run_command(&self, text: &str) { + let _ = self.sink.send(Event::Error { + message: format!( + "a llama.cpp session has no commands of its own, so {text} means nothing to it." + ), + }); + } + fn compact(&self) { let _ = self.sink.send(Event::Error { message: "llama.cpp has no compaction. When the context fills, start a new session." diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index a5aad1a..a608d6f 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -18,7 +18,7 @@ pub mod process; pub mod transcript; pub mod transport; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -31,7 +31,7 @@ use crate::config::{ Config, DriverKind, ProviderConfig, SessionConfig, SetupConfig, SshConfig, TokenEntry, }; use claude::ClaudeDriver; -use driver::{Driver, Event, EventSink, ImageRef, SessionStatus}; +use driver::{Driver, Event, EventSink, ImageRef, SessionCommand, SessionStatus}; use echo::EchoDriver; use llama::LlamaDriver; use transcript::{SeqEvent, Transcript}; @@ -103,7 +103,11 @@ pub struct SessionInfo { /// keeps current. Cheap to clone-by-`Arc` into request handlers. pub struct LiveSession { meta: SessionConfig, - driver: Box, + driver: Arc, + /// Commands asked for and not yet run, oldest first, with the pump + /// that will run them. Shared with that pump, which is what notices + /// the boundary. + commands: Arc, /// The same channel the driver reports into; the manager injects /// `UserMessage`/`Answered` here so they take a sequence number in /// order with everything else. @@ -113,6 +117,73 @@ pub struct LiveSession { shared: Arc, } +/// Commands waiting for the session to be between turns. +/// +/// One implementation for every provider, because the rule is about +/// sessions rather than about a dialect: a line written into a running +/// turn is read by the model, so anything meant for the *session* waits +/// for the turn to end. Drivers therefore never have to think about it, +/// and a new provider cannot get it wrong by omission. +struct Commands { + driver: Arc, + sink: EventSink, + waiting: Mutex>, +} + +impl Commands { + /// Runs `command` now if the session is between turns, and otherwise + /// holds it until it is. Either way the phone is told which happened. + fn submit(&self, command: SessionCommand, status: SessionStatus) { + let id = random_hex(); + let text = command.label(); + if status == SessionStatus::Idle { + let _ = self.sink.send(Event::CommandSent { id, text }); + command.apply(self.driver.as_ref()); + return; + } + let _ = self.sink.send(Event::CommandQueued { + id: id.clone(), + text, + }); + self.waiting.lock().unwrap().push_back((id, command)); + } + + /// The turn ended, so the oldest waiting command can go. One, not all + /// of them: running a command starts a turn of its own, and the next + /// boundary is where the one after it belongs. + fn take_one(&self) { + let Some((id, command)) = self.waiting.lock().unwrap().pop_front() else { + return; + }; + let _ = self.sink.send(Event::CommandSent { + id, + text: command.label(), + }); + command.apply(self.driver.as_ref()); + } + + /// Gives up on everything held, because the session cannot run them. + /// + /// Reported rather than dropped, for the reason the message queue in + /// `claude.rs` reports its own: somebody asked for these and nothing + /// else would ever say they did not happen. + fn abandon(&self, why: &str) { + let lost: Vec = self + .waiting + .lock() + .unwrap() + .drain(..) + .map(|(_, command)| command.label()) + .collect(); + if lost.is_empty() { + return; + } + let _ = self.sink.send(Event::Error { + message: format!("{why}, so {} never ran", lost.join(" and ")), + }); + } +} + /// The pump-maintained view of a session, read by the list endpoint. /// `model` also lives here (not in the immutable meta) because it can /// change mid-session via `set_model`. @@ -171,6 +242,13 @@ impl LiveSession { self.driver.answer_question(question_id, answers); } + /// Asks the session to run a command on itself, now or at the next + /// boundary. See [`Commands`] for why it may not be now. + pub fn run_command(&self, command: SessionCommand) { + self.commands + .submit(command, *self.shared.status.lock().unwrap()); + } + pub fn interrupt(&self) { self.driver.interrupt(); } @@ -182,8 +260,11 @@ impl LiveSession { self.driver.detach(); } + /// Compacts at the next boundary. Through the command queue like + /// every other instruction to the session, so pressing it during a + /// turn holds rather than writing into that turn. pub fn compact(&self) { - self.driver.compact(); + self.run_command(SessionCommand::Compact); } pub fn subscribe(&self) -> broadcast::Receiver { @@ -720,8 +801,12 @@ impl SessionManager { candidate.save(&self.config_path)?; inner.config = candidate; if let Some(session) = inner.live.get(id) { + // The name is this server's and changes now. Telling whatever + // runs the session is a command, and commands wait for the + // turn to end -- so the list shows the new name immediately + // and the CLI is told at the next boundary. *session.shared.title.lock().unwrap() = title.to_string(); - session.driver.set_title(title); + session.run_command(SessionCommand::SetTitle(title.to_string())); } Ok(()) } @@ -999,9 +1084,9 @@ fn launch( ); } - let driver: Box = match provider.kind { - DriverKind::Echo => Box::new(EchoDriver::new(sink.clone())), - DriverKind::LlamaCpp => Box::new(LlamaDriver::launch( + let driver: Arc = match provider.kind { + DriverKind::Echo => Arc::new(EchoDriver::new(sink.clone())), + DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch( &meta, provider, &Transport::for_setup(setup), @@ -1010,7 +1095,7 @@ fn launch( &dir, sink.clone(), )?), - DriverKind::ClaudeCli => Box::new(ClaudeDriver::launch( + DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch( &meta, provider, &Transport::for_setup(setup), @@ -1019,16 +1104,24 @@ fn launch( )?), }; + let commands = Arc::new(Commands { + driver: Arc::clone(&driver), + sink: sink.clone(), + waiting: Mutex::new(VecDeque::new()), + }); + tokio::spawn(pump( transcript, source, Arc::clone(&shared), events.clone(), + Arc::clone(&commands), )); Ok(Arc::new(LiveSession { meta, driver, + commands, sink, events, transcript_path, @@ -1072,6 +1165,7 @@ async fn pump( mut source: mpsc::UnboundedReceiver, shared: Arc, events: broadcast::Sender, + commands: Arc, ) { while let Some(event) = source.recv().await { let ts = now(); @@ -1116,6 +1210,19 @@ async fn pump( } *shared.last_activity.lock().unwrap() = ts; *shared.written.lock().unwrap() += 1; + // The boundary a held command was waiting for, and the one + // place that sees every driver's. Done after the status is + // recorded, so the command that runs next sees an idle + // session and goes out rather than queueing behind itself. + match &entry.event { + Event::Status { + state: SessionStatus::Idle, + } => commands.take_one(), + Event::Status { + state: SessionStatus::Exited, + } => commands.abandon("this session's process has exited"), + _ => {} + } // No subscribers is fine; the transcript already has it. let _ = events.send(entry); } @@ -1328,6 +1435,92 @@ mod tests { assert_eq!(manager.sessions()[0].title, "the one about paging"); } + #[tokio::test] + async fn a_command_waits_for_the_turn_to_end() { + 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 that will still be going when the command arrives. + session.send_message("/slow 1".to_string(), Vec::new()); + collect_until(&mut rx, |event| { + matches!( + event, + Event::Status { + state: SessionStatus::Running + } + ) + }) + .await; + + session.run_command(SessionCommand::Raw("/tool held".to_string())); + let seen = collect_until(&mut rx, |event| { + matches!(event, Event::CommandQueued { .. }) + }) + .await; + let Some(Event::CommandQueued { id, text }) = + seen.iter().map(|entry| entry.event.clone()).next_back() + else { + panic!("expected the command to be held: {seen:?}"); + }; + assert_eq!(text, "/tool held"); + // Held, not run: nothing of the command has reached the session. + assert!( + !seen + .iter() + .any(|entry| matches!(entry.event, Event::ToolStart { .. })), + "a held command must not have run yet" + ); + + // The turn ends, and it goes. + let seen = collect_until(&mut rx, |event| matches!(event, Event::ToolStart { .. })).await; + assert!( + seen.iter().any(|entry| matches!( + &entry.event, + Event::CommandSent { id: sent, .. } if *sent == id + )), + "the same command has to be reported as sent: {seen:?}" + ); + } + + #[tokio::test] + async fn a_command_on_an_idle_session_goes_straight_out() { + 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(); + + session.run_command(SessionCommand::Raw("/tool now".to_string())); + let seen = collect_until(&mut rx, |event| matches!(event, Event::ToolStart { .. })).await; + // Sent, and never queued: a session between turns has nothing to + // wait for, and a phone should not draw a bubble that resolves in + // the same frame. + assert!( + seen.iter() + .any(|entry| matches!(entry.event, Event::CommandSent { .. })) + ); + assert!( + !seen + .iter() + .any(|entry| matches!(entry.event, Event::CommandQueued { .. })) + ); + } + #[tokio::test] async fn questions_round_trip_through_answer() { let dir = tempfile::tempdir().expect("tempdir");