Make a command a thing the app knows, and hold it until it can run
Typing "/" now suggests what this app understands -- `/compact` and `/rename <name>` -- with a line each about what they do, and anything else beginning with a slash is passed to whatever runs the session, because a dialect's own vocabulary grows without this list. None of them are messages, and that is the substance of the change. A line written into a running turn is read by the *model*, so a command sent mid-turn either does nothing or arrives as text somebody has to puzzle over. They now wait for the turn to end. The waiting is done once for every provider, in the pump that already watches every event for the boundary, rather than in each driver where a new provider could get it wrong by leaving it out. Waiting is a state, so it is on screen: the command sits at the reader's end of the conversation in blue, with a spinner and "waiting for this turn to end", and becomes an ordinary blue row when it goes. Blue because these are about the session rather than about the task -- the same blue a compaction already used, which is now one colour with one name rather than two. Renaming from the settings screen sends exactly this, so it waits and draws the same way. The name itself is not held: it is this server's own datum, so the list and the header change at once and only telling the session waits. Echo grew the same split, which is where the bug in it showed: its commands are its messages, so running one announced a `MessageTaken` as well, and the same line drew twice -- once blue, once purple. A command owes no announcement; the manager has already recorded that it was sent. Watched rather than reasoned about: `/compact` during a 25 second turn held with its bubble up, went out when the turn ended, and the compaction that followed reported what it recovered.
This commit is contained in:
1 parent
bebaae7a94
commit
749b2db287
13 files changed
+678
-93
No files matched your search
@@ -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.
|
* Asks the session to summarise its own history and carry on from the summary.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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<SessionCommand> {
|
||||||
|
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<SessionCommand>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,11 +76,11 @@ fun CompactingRow(seconds: Long?, modifier: Modifier = Modifier) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
// The colour is stated beside the fill rather than inherited: a semantic colour has
|
// 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.
|
// to carry its own contrast, since the surface under it will not change to rescue it.
|
||||||
color = compactingColor,
|
color = commandColor,
|
||||||
)
|
)
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||||
color = compactingColor,
|
color = commandColor,
|
||||||
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ sealed class SessionEvent {
|
|||||||
*/
|
*/
|
||||||
data class PeerMessage(val from: String, val text: String) : 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()
|
data class Status(val state: String) : SessionEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,6 +155,9 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
)
|
)
|
||||||
"peerMessage" ->
|
"peerMessage" ->
|
||||||
SessionEvent.PeerMessage(body.getString("from"), body.getString("text"))
|
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"))
|
"status" -> SessionEvent.Status(body.getString("state"))
|
||||||
"settings" ->
|
"settings" ->
|
||||||
SessionEvent.Settings(
|
SessionEvent.Settings(
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ fun StatusText(status: String) {
|
|||||||
when (status) {
|
when (status) {
|
||||||
"awaitingInput" -> "your turn" to awaitingColor
|
"awaitingInput" -> "your turn" to awaitingColor
|
||||||
"running" -> "running" to runningColor
|
"running" -> "running" to runningColor
|
||||||
"compacting" -> "compacting" to compactingColor
|
"compacting" -> "compacting" to commandColor
|
||||||
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
|
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
// Said in words, because it differs in kind from the others rather than in degree:
|
// 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
|
// the session is not idle and has not exited, nobody has been able to find out
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ sealed class TranscriptItem {
|
|||||||
data class PeerNote(override val seq: Long, val from: String, val text: String) :
|
data class PeerNote(override val seq: Long, val from: String, val text: String) :
|
||||||
TranscriptItem()
|
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). */
|
/** Placeholder row for events this build can't render (newer kinds). */
|
||||||
data class Note(override val seq: Long, val text: String) : TranscriptItem()
|
data class Note(override val seq: Long, val text: String) : TranscriptItem()
|
||||||
|
|
||||||
@@ -250,7 +258,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
}
|
}
|
||||||
is SessionEvent.PeerMessage ->
|
is SessionEvent.PeerMessage ->
|
||||||
items + TranscriptItem.PeerNote(entry.seq, event.from, event.text)
|
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.
|
// Screen-level state, not transcript rows -- see SessionScreen.
|
||||||
|
is SessionEvent.CommandQueued -> 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)
|
||||||
@@ -320,6 +330,11 @@ fun SessionScreen(
|
|||||||
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
||||||
// What this session is set to now, seeded from the row that opened it and
|
// 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.
|
// 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 model by remember { mutableStateOf(summary.model) }
|
||||||
var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") }
|
var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") }
|
||||||
// The models this provider actually offers, asked of the server rather
|
// 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
|
// reading of events: after everything taken in, not yet taken in
|
||||||
// itself.
|
// itself.
|
||||||
var queued by remember { mutableStateOf(listOf<String>()) }
|
var queued by remember { mutableStateOf(listOf<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.
|
||||||
|
var waitingCommands by remember { mutableStateOf(listOf<Pair<String, String>>()) }
|
||||||
val running = status == "running" || status == "compacting"
|
val running = status == "running" || status == "compacting"
|
||||||
var moreHistory by remember { mutableStateOf(true) }
|
var moreHistory by remember { mutableStateOf(true) }
|
||||||
var loadingHistory by remember { mutableStateOf(false) }
|
var loadingHistory by remember { mutableStateOf(false) }
|
||||||
@@ -393,6 +412,14 @@ fun SessionScreen(
|
|||||||
// 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
|
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
|
// Kept as well as folded. Folding is one-way -- a tool's
|
||||||
// start and end become one row -- so a page arriving in
|
// start and end become one row -- so a page arriving in
|
||||||
// front of what is already here cannot be stitched on
|
// front of what is already here cannot be stitched on
|
||||||
@@ -624,6 +651,25 @@ fun SessionScreen(
|
|||||||
val text = input.trim()
|
val text = input.trim()
|
||||||
val attachments = pendingAttachments
|
val attachments = pendingAttachments
|
||||||
if (text.isEmpty() && attachments.isEmpty()) return
|
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 = ""
|
input = ""
|
||||||
pendingAttachments = emptyList()
|
pendingAttachments = emptyList()
|
||||||
if (running && text.isNotEmpty()) queued = queued + text
|
if (running && text.isNotEmpty()) queued = queued + text
|
||||||
@@ -672,7 +718,7 @@ fun SessionScreen(
|
|||||||
) {
|
) {
|
||||||
TextButton(onClick = onBack) { Text("Back") }
|
TextButton(onClick = onBack) { Text("Back") }
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(summary.title, style = MaterialTheme.typography.titleMedium)
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
Text(
|
Text(
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
summary.provider,
|
summary.provider,
|
||||||
@@ -739,9 +785,12 @@ fun SessionScreen(
|
|||||||
// Below the working indicator, because that is where they
|
// Below the working indicator, because that is where they
|
||||||
// are in the session's reading of events: after everything
|
// are in the session's reading of events: after everything
|
||||||
// it has taken in, and not yet taken in themselves.
|
// it has taken in, and not yet taken in themselves.
|
||||||
if (queued.isNotEmpty()) {
|
if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
|
||||||
item(key = "queued") {
|
item(key = "queued") {
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
waitingCommands.forEach { (_, text) ->
|
||||||
|
CommandBubble(text, waiting = true)
|
||||||
|
}
|
||||||
queued.forEach { text -> UserBubble(text, pending = true) }
|
queued.forEach { text -> UserBubble(text, pending = true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -867,6 +916,7 @@ fun SessionScreen(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
is TranscriptItem.CommandRow -> CommandBubble(item.text)
|
||||||
is TranscriptItem.CompactedNote -> CompactedRow(item)
|
is TranscriptItem.CompactedNote -> CompactedRow(item)
|
||||||
is TranscriptItem.PeerNote ->
|
is TranscriptItem.PeerNote ->
|
||||||
PeerMessageRow(
|
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
|
// Always enabled -- a send while the session is running becomes a
|
||||||
// steering message injected at the next tool boundary, which is
|
// steering message injected at the next tool boundary, which is
|
||||||
// the point of the whole app.
|
// the point of the whole app.
|
||||||
|
|||||||
@@ -110,15 +110,15 @@ val failedColor: Color
|
|||||||
@Composable get() = MaterialTheme.colorScheme.error
|
@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
|
* Its own colour because it is its own kind of work. Everything else a session does is progress
|
||||||
* through the task; this is the session rewriting what it remembers, it can take minutes, and
|
* through what was asked of it; this is the session acting on itself -- rewriting what it
|
||||||
* nothing it produces appears in the transcript until it is over. A reader who has learned that
|
* remembers, taking a new name -- and none of it appears in the transcript as an answer to
|
||||||
* blue means "not stuck, but not answering you either" has learned the only thing that
|
* anything. A reader who has learned that blue means "not stuck, but not replying to you either"
|
||||||
* distinguishes it from a session that has hung.
|
* has learned the thing that distinguishes it from a session that has hung.
|
||||||
*/
|
*/
|
||||||
val compactingColor: Color
|
val commandColor: Color
|
||||||
@Composable get() = Mocha.Blue
|
@Composable get() = Mocha.Blue
|
||||||
|
|
||||||
/** Waiting on a person: a question, a permission, a turn that is theirs. */
|
/** Waiting on a person: a question, a permission, a turn that is theirs. */
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
//! POST /sessions/{id}/interrupt
|
//! POST /sessions/{id}/interrupt
|
||||||
//! POST /sessions/{id}/title {title}
|
//! POST /sessions/{id}/title {title}
|
||||||
//! POST /sessions/{id}/model {model}
|
//! 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}/compact
|
||||||
//! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message
|
//! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message
|
||||||
//! GET /sessions/{id}/files/{name} images the session produced or was sent
|
//! 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::StreamExt;
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
||||||
|
use crate::session::driver::SessionCommand;
|
||||||
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};
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
|||||||
.route("/sessions/{id}/model", post(set_model))
|
.route("/sessions/{id}/model", post(set_model))
|
||||||
.route("/sessions/{id}/permission-mode", post(set_permission_mode))
|
.route("/sessions/{id}/permission-mode", post(set_permission_mode))
|
||||||
.route("/sessions/{id}/compact", post(compact))
|
.route("/sessions/{id}/compact", post(compact))
|
||||||
|
.route("/sessions/{id}/command", post(command))
|
||||||
.route("/sessions/{id}/attachments", post(upload_attachment))
|
.route("/sessions/{id}/attachments", post(upload_attachment))
|
||||||
.route("/sessions/{id}/files/{name}", get(serve_file))
|
.route("/sessions/{id}/files/{name}", get(serve_file))
|
||||||
// Phone photos overflow axum's 2 MB default body cap.
|
// Phone photos overflow axum's 2 MB default body cap.
|
||||||
@@ -737,6 +740,39 @@ async fn set_permission_mode(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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<Arc<SessionManager>>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
axum::Json(body): axum::Json<CommandRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
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(
|
async fn compact(
|
||||||
State(manager): State<Arc<SessionManager>>,
|
State(manager): State<Arc<SessionManager>>,
|
||||||
UrlPath(id): UrlPath<String>,
|
UrlPath(id): UrlPath<String>,
|
||||||
|
|||||||
@@ -398,6 +398,29 @@ impl ClaudeDriver {
|
|||||||
let _ = self.to_child.send(line);
|
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.
|
/// Sends a control request, remembering what it asked for.
|
||||||
///
|
///
|
||||||
/// `confirms` is the setting this request will have made if the CLI
|
/// `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) {
|
fn set_title(&self, title: &str) {
|
||||||
// The CLI's own mechanism, and a local command rather than a
|
// The CLI's own mechanism, and a local command rather than a
|
||||||
// control request -- `set_session_name` is not a subtype it
|
// control request -- `set_session_name` is not a subtype it
|
||||||
@@ -530,32 +562,11 @@ impl Driver for ClaudeDriver {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.queue.lock().unwrap().running = true;
|
self.local_command(format!("/rename {title}"));
|
||||||
self.send_line(
|
|
||||||
json!({"type": "user", "message": {"role": "user", "content": [
|
|
||||||
{"type": "text", "text": format!("/rename {title}")}
|
|
||||||
]}})
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compact(&self) {
|
fn compact(&self) {
|
||||||
// A turn is in flight from here. The CLI answers `/compact` like
|
self.local_command("/compact".to_string());
|
||||||
// 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(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detach(&self) {
|
fn detach(&self) {
|
||||||
|
|||||||
@@ -223,11 +223,68 @@ pub enum Event {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
trigger: Option<String>,
|
trigger: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// 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 {
|
Error {
|
||||||
message: String,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum SessionStatus {
|
pub enum SessionStatus {
|
||||||
@@ -300,6 +357,18 @@ pub trait Driver: Send + Sync {
|
|||||||
/// `/rename` afterwards, which is what puts the same name in its own
|
/// `/rename` afterwards, which is what puts the same name in its own
|
||||||
/// session picker and in what other agents see.
|
/// session picker and in what other agents see.
|
||||||
fn set_title(&self, title: &str);
|
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`.
|
/// pi: native compaction; claude: `/compact`.
|
||||||
fn compact(&self);
|
fn compact(&self);
|
||||||
/// Stop attending to the process but leave it running, because this
|
/// Stop attending to the process but leave it running, because this
|
||||||
|
|||||||
+81
-49
@@ -192,51 +192,14 @@ impl EchoDriver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new(sink: EventSink) -> Self {
|
/// One typed line, whether it arrived as a message or as a command.
|
||||||
let driver = Self {
|
///
|
||||||
sink,
|
/// `announce` is the difference and it is the whole of it: a message
|
||||||
pending_questions: Mutex::new(Vec::new()),
|
/// is announced with `MessageTaken`, which is what puts it in the
|
||||||
busy: Arc::new(AtomicBool::new(false)),
|
/// transcript, and a command is not -- the manager has already
|
||||||
queued: Arc::new(Mutex::new(Vec::new())),
|
/// recorded that one was sent, and saying so twice drew the same
|
||||||
};
|
/// line in both colours.
|
||||||
driver.emit(Event::Status {
|
fn handle(&self, text: String, _images: Vec<ImageRef>, announce: bool) {
|
||||||
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<Vec<String>>, 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<ImageRef>) {
|
|
||||||
let sink = self.sink.clone();
|
let sink = self.sink.clone();
|
||||||
|
|
||||||
// Mid-turn messages are held rather than answered, the way a real
|
// 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
|
// `MessageTaken` per message, and a command that quietly vanishes
|
||||||
// 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") {
|
||||||
self.emit(Event::MessageTaken { text: text.clone() });
|
if announce {
|
||||||
|
self.emit(Event::MessageTaken { text: text.clone() });
|
||||||
|
}
|
||||||
self.emit(Event::PeerMessage {
|
self.emit(Event::PeerMessage {
|
||||||
from: "dev-updater-f5".to_string(),
|
from: "dev-updater-f5".to_string(),
|
||||||
text: if rest.trim().is_empty() {
|
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;
|
// way. `Driver::compact` is what the manager's own route calls;
|
||||||
// this is the typed path onto it.
|
// this is the typed path onto it.
|
||||||
if text.trim() == "/compact" {
|
if text.trim() == "/compact" {
|
||||||
self.emit(Event::MessageTaken { text });
|
if announce {
|
||||||
|
self.emit(Event::MessageTaken { text });
|
||||||
|
}
|
||||||
self.compact();
|
self.compact();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if text.trim() == "/ask" {
|
if text.trim() == "/ask" {
|
||||||
self.emit(Event::MessageTaken { text });
|
if announce {
|
||||||
|
self.emit(Event::MessageTaken { text });
|
||||||
|
}
|
||||||
self.ask_user_question();
|
self.ask_user_question();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -352,7 +321,9 @@ impl Driver for EchoDriver {
|
|||||||
// anyway: a driver that skips this leaves the phone holding a
|
// anyway: a driver that skips this leaves the phone holding a
|
||||||
// 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.
|
||||||
send(Event::MessageTaken { text: text.clone() });
|
if announce {
|
||||||
|
send(Event::MessageTaken { text: text.clone() });
|
||||||
|
}
|
||||||
send(Event::Status {
|
send(Event::Status {
|
||||||
state: SessionStatus::Running,
|
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<Vec<String>>, 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<ImageRef>) {
|
||||||
|
// 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]) {
|
fn answer_question(&self, id: &str, answers: &[String]) {
|
||||||
let answer = answers.join(", ");
|
let answer = answers.join(", ");
|
||||||
let (answered, waiting) = {
|
let (answered, waiting) = {
|
||||||
|
|||||||
@@ -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) {
|
fn compact(&self) {
|
||||||
let _ = self.sink.send(Event::Error {
|
let _ = self.sink.send(Event::Error {
|
||||||
message: "llama.cpp has no compaction. When the context fills, start a new session."
|
message: "llama.cpp has no compaction. When the context fills, start a new session."
|
||||||
|
|||||||
+202
-9
@@ -18,7 +18,7 @@ pub mod process;
|
|||||||
pub mod transcript;
|
pub mod transcript;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -31,7 +31,7 @@ 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, SessionStatus};
|
use driver::{Driver, Event, EventSink, ImageRef, SessionCommand, SessionStatus};
|
||||||
use echo::EchoDriver;
|
use echo::EchoDriver;
|
||||||
use llama::LlamaDriver;
|
use llama::LlamaDriver;
|
||||||
use transcript::{SeqEvent, Transcript};
|
use transcript::{SeqEvent, Transcript};
|
||||||
@@ -103,7 +103,11 @@ pub struct SessionInfo {
|
|||||||
/// keeps current. Cheap to clone-by-`Arc` into request handlers.
|
/// keeps current. Cheap to clone-by-`Arc` into request handlers.
|
||||||
pub struct LiveSession {
|
pub struct LiveSession {
|
||||||
meta: SessionConfig,
|
meta: SessionConfig,
|
||||||
driver: Box<dyn Driver>,
|
driver: Arc<dyn Driver>,
|
||||||
|
/// 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<Commands>,
|
||||||
/// The same channel the driver reports into; the manager injects
|
/// The same channel the driver reports into; the manager injects
|
||||||
/// `UserMessage`/`Answered` here so they take a sequence number in
|
/// `UserMessage`/`Answered` here so they take a sequence number in
|
||||||
/// order with everything else.
|
/// order with everything else.
|
||||||
@@ -113,6 +117,73 @@ pub struct LiveSession {
|
|||||||
shared: Arc<Shared>,
|
shared: Arc<Shared>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<dyn Driver>,
|
||||||
|
sink: EventSink,
|
||||||
|
waiting: Mutex<VecDeque<(String, SessionCommand)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> = 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.
|
/// The pump-maintained view of a session, read by the list endpoint.
|
||||||
/// `model` also lives here (not in the immutable meta) because it can
|
/// `model` also lives here (not in the immutable meta) because it can
|
||||||
/// change mid-session via `set_model`.
|
/// change mid-session via `set_model`.
|
||||||
@@ -171,6 +242,13 @@ impl LiveSession {
|
|||||||
self.driver.answer_question(question_id, answers);
|
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) {
|
pub fn interrupt(&self) {
|
||||||
self.driver.interrupt();
|
self.driver.interrupt();
|
||||||
}
|
}
|
||||||
@@ -182,8 +260,11 @@ impl LiveSession {
|
|||||||
self.driver.detach();
|
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) {
|
pub fn compact(&self) {
|
||||||
self.driver.compact();
|
self.run_command(SessionCommand::Compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<SeqEvent> {
|
pub fn subscribe(&self) -> broadcast::Receiver<SeqEvent> {
|
||||||
@@ -720,8 +801,12 @@ impl SessionManager {
|
|||||||
candidate.save(&self.config_path)?;
|
candidate.save(&self.config_path)?;
|
||||||
inner.config = candidate;
|
inner.config = candidate;
|
||||||
if let Some(session) = inner.live.get(id) {
|
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.shared.title.lock().unwrap() = title.to_string();
|
||||||
session.driver.set_title(title);
|
session.run_command(SessionCommand::SetTitle(title.to_string()));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -999,9 +1084,9 @@ fn launch(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let driver: Box<dyn Driver> = match provider.kind {
|
let driver: Arc<dyn Driver> = match provider.kind {
|
||||||
DriverKind::Echo => Box::new(EchoDriver::new(sink.clone())),
|
DriverKind::Echo => Arc::new(EchoDriver::new(sink.clone())),
|
||||||
DriverKind::LlamaCpp => Box::new(LlamaDriver::launch(
|
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
|
||||||
&meta,
|
&meta,
|
||||||
provider,
|
provider,
|
||||||
&Transport::for_setup(setup),
|
&Transport::for_setup(setup),
|
||||||
@@ -1010,7 +1095,7 @@ fn launch(
|
|||||||
&dir,
|
&dir,
|
||||||
sink.clone(),
|
sink.clone(),
|
||||||
)?),
|
)?),
|
||||||
DriverKind::ClaudeCli => Box::new(ClaudeDriver::launch(
|
DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch(
|
||||||
&meta,
|
&meta,
|
||||||
provider,
|
provider,
|
||||||
&Transport::for_setup(setup),
|
&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(
|
tokio::spawn(pump(
|
||||||
transcript,
|
transcript,
|
||||||
source,
|
source,
|
||||||
Arc::clone(&shared),
|
Arc::clone(&shared),
|
||||||
events.clone(),
|
events.clone(),
|
||||||
|
Arc::clone(&commands),
|
||||||
));
|
));
|
||||||
|
|
||||||
Ok(Arc::new(LiveSession {
|
Ok(Arc::new(LiveSession {
|
||||||
meta,
|
meta,
|
||||||
driver,
|
driver,
|
||||||
|
commands,
|
||||||
sink,
|
sink,
|
||||||
events,
|
events,
|
||||||
transcript_path,
|
transcript_path,
|
||||||
@@ -1072,6 +1165,7 @@ async fn pump(
|
|||||||
mut source: mpsc::UnboundedReceiver<Event>,
|
mut source: mpsc::UnboundedReceiver<Event>,
|
||||||
shared: Arc<Shared>,
|
shared: Arc<Shared>,
|
||||||
events: broadcast::Sender<SeqEvent>,
|
events: broadcast::Sender<SeqEvent>,
|
||||||
|
commands: Arc<Commands>,
|
||||||
) {
|
) {
|
||||||
while let Some(event) = source.recv().await {
|
while let Some(event) = source.recv().await {
|
||||||
let ts = now();
|
let ts = now();
|
||||||
@@ -1116,6 +1210,19 @@ async fn pump(
|
|||||||
}
|
}
|
||||||
*shared.last_activity.lock().unwrap() = ts;
|
*shared.last_activity.lock().unwrap() = ts;
|
||||||
*shared.written.lock().unwrap() += 1;
|
*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.
|
// No subscribers is fine; the transcript already has it.
|
||||||
let _ = events.send(entry);
|
let _ = events.send(entry);
|
||||||
}
|
}
|
||||||
@@ -1328,6 +1435,92 @@ mod tests {
|
|||||||
assert_eq!(manager.sessions()[0].title, "the one about paging");
|
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]
|
#[tokio::test]
|
||||||
async fn questions_round_trip_through_answer() {
|
async fn questions_round_trip_through_answer() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
|||||||
Reference in new issue
Block a user