Say when a session is working, and what it was told
Three things a phone could not see, all of them the same shape: the session was doing something and nothing on screen said so. A turn nobody here started never reported itself. `Running` was sent where a message was *sent*, so a session picked up mid-turn, one compacting on its own, or one another agent wrote to sat there reading as idle until it finished. The driver now says it from what it observes -- output that could only come from a turn in flight -- which is the same set of events that already announced a steer, with the ends swapped. An imported session had it worse: nothing but replayed lines ever reaches it, and a status was not among them, so it was permanently whatever it was when it was adopted. Its file does not record a turn ending, but it does record why each assistant message stopped, and `tool_use` versus anything else answers it. A record that says nothing leaves the status alone rather than voting for idle. Messages from other agents were dropped outright: the CLI marks them meta, and this replayed everything except meta. They are now a row of their own, closed by default like a tool call, named for the session that sent it -- not the reader's own bubble, because they did not say it, and a session working on something this phone never asked for is exactly what one of these explains. Measured against a real session file rather than guessed: the peer record carries the sender's name and the message body in `origin`, beside a copy wrapped for the model to read.
This commit is contained in:
1 parent
f18639e4b1
commit
404066fa7d
11 files changed
+577
-29
No files matched your search
@@ -1,7 +1,9 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -51,3 +53,56 @@ fun compactionSummary(item: TranscriptItem.CompactedNote): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun tokens(count: Long): String = "%,d".format(count)
|
private fun tokens(count: Long): String = "%,d".format(count)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the working indicator goes while a compaction is running.
|
||||||
|
*
|
||||||
|
* A bar across the whole row rather than the spinner an ordinary turn gets, because a compaction is
|
||||||
|
* not an ordinary turn: nothing arrives in the transcript while it runs, so the row it occupies is
|
||||||
|
* the only thing on screen that is moving, and at the width of a spinner that reads as a session
|
||||||
|
* that might have hung.
|
||||||
|
*
|
||||||
|
* The bar is indeterminate, and that is a statement rather than an omission. The CLI says a
|
||||||
|
* compaction has started and then says nothing at all until it has finished -- measured, not
|
||||||
|
* assumed -- so there is no fraction to fill, and a bar that crept along at the pace of the last
|
||||||
|
* compaction would be this screen inventing the part nobody sent it. What it can honestly say is
|
||||||
|
* that work is happening and for how long, which is [compactingLabel].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CompactingRow(seconds: Long?, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
compactingLabel(seconds),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||||
|
color = compactingColor,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the working indicator says while a compaction is running.
|
||||||
|
*
|
||||||
|
* Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a
|
||||||
|
* compaction has begun and then says nothing until it has finished, so any bar, percentage or
|
||||||
|
* estimate here would be this screen's guess wearing a measurement's clothes. Knowing it has been
|
||||||
|
* going forty seconds is what a reader actually wants -- it is the difference between waiting and
|
||||||
|
* going to look at why.
|
||||||
|
*
|
||||||
|
* [seconds] is null when this device did not see the compaction start, which is what opening a
|
||||||
|
* session that is already compacting looks like. That case says only "compacting": no number is the
|
||||||
|
* honest answer, and a number counted from the moment the screen opened would be wrong in the
|
||||||
|
* direction that matters, since a compaction somebody is asking about is a long one.
|
||||||
|
*/
|
||||||
|
fun compactingLabel(seconds: Long?): String =
|
||||||
|
when {
|
||||||
|
seconds == null -> "compacting"
|
||||||
|
seconds < 60 -> "compacting ${seconds}s"
|
||||||
|
else -> "compacting ${seconds / 60}m ${seconds % 60}s"
|
||||||
|
}
|
||||||
@@ -37,6 +37,15 @@ sealed class SessionEvent {
|
|||||||
|
|
||||||
data class Answered(val id: String, val answer: String) : SessionEvent()
|
data class Answered(val id: String, val answer: String) : SessionEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message another agent sent this session.
|
||||||
|
*
|
||||||
|
* Not a [UserMessage]: nobody holding the phone said it, and drawing it in their voice would
|
||||||
|
* claim they had. It is also the explanation for a session that starts working on something
|
||||||
|
* this device never asked for.
|
||||||
|
*/
|
||||||
|
data class PeerMessage(val from: String, val text: String) : SessionEvent()
|
||||||
|
|
||||||
data class Status(val state: String) : SessionEvent()
|
data class Status(val state: String) : SessionEvent()
|
||||||
|
|
||||||
data class UsageDelta(val tokens: Long) : SessionEvent()
|
data class UsageDelta(val tokens: Long) : SessionEvent()
|
||||||
@@ -96,6 +105,8 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
about = body.optString("about").ifEmpty { null },
|
about = body.optString("about").ifEmpty { null },
|
||||||
)
|
)
|
||||||
"answered" -> SessionEvent.Answered(body.getString("id"), body.getString("answer"))
|
"answered" -> SessionEvent.Answered(body.getString("id"), body.getString("answer"))
|
||||||
|
"peerMessage" ->
|
||||||
|
SessionEvent.PeerMessage(body.getString("from"), body.getString("text"))
|
||||||
"status" -> SessionEvent.Status(body.getString("state"))
|
"status" -> SessionEvent.Status(body.getString("state"))
|
||||||
"usageDelta" -> SessionEvent.UsageDelta(body.getLong("tokens"))
|
"usageDelta" -> SessionEvent.UsageDelta(body.getLong("tokens"))
|
||||||
"compacted" ->
|
"compacted" ->
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
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.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message another agent sent this session, closed until somebody asks.
|
||||||
|
*
|
||||||
|
* Closed by default, like a tool call and for the same reason: these are long, there can be several
|
||||||
|
* in a row, and what a reader scanning the transcript needs from one is that it happened and who
|
||||||
|
* sent it. The first line comes with the heading because a name alone does not say which message
|
||||||
|
* this was.
|
||||||
|
*
|
||||||
|
* Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a
|
||||||
|
* transcript that puts it in their voice is making a claim about who asked for the work that
|
||||||
|
* follows -- which is exactly the question a peer message is usually the answer to.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PeerMessageRow(
|
||||||
|
item: TranscriptItem.PeerNote,
|
||||||
|
expanded: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
|
||||||
|
if (!expanded) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
item.text.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
// The head, not the tail: a message is identified by how it opens.
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expanded) MarkdownText(item.text, Modifier.padding(top = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -261,7 +261,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 runningColor
|
"compacting" -> "compacting" to compactingColor
|
||||||
"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
|
||||||
@@ -271,9 +271,12 @@ fun StatusText(status: String) {
|
|||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (status == "running" || status == "compacting") {
|
if (status == "running" || status == "compacting") {
|
||||||
|
// The same colour as the word beside it: the two are one signal, and a spinner in
|
||||||
|
// the theme's accent says the state is something other than what the label says.
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.width(14.dp).height(14.dp),
|
modifier = Modifier.width(14.dp).height(14.dp),
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
|
color = color,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.PickVisualMediaRequest
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -125,6 +126,14 @@ sealed class TranscriptItem {
|
|||||||
/** An image by server-side ref, fetched from the session's files route. */
|
/** An image by server-side ref, fetched from the session's files route. */
|
||||||
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
|
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message another agent sent this session.
|
||||||
|
*
|
||||||
|
* Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters.
|
||||||
|
*/
|
||||||
|
data class PeerNote(override val seq: Long, val from: String, 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()
|
||||||
|
|
||||||
@@ -222,6 +231,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
else -> it
|
else -> it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is SessionEvent.PeerMessage ->
|
||||||
|
items + TranscriptItem.PeerNote(entry.seq, event.from, event.text)
|
||||||
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)
|
||||||
is SessionEvent.Image ->
|
is SessionEvent.Image ->
|
||||||
@@ -269,6 +280,11 @@ fun SessionScreen(
|
|||||||
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
||||||
var status by remember { mutableStateOf(summary.status) }
|
var status by remember { mutableStateOf(summary.status) }
|
||||||
var totalTokens by remember { mutableLongStateOf(0L) }
|
var totalTokens by remember { mutableLongStateOf(0L) }
|
||||||
|
// When this screen saw the current compaction start, on this device's own clock, and how long
|
||||||
|
// ago that is. See `compactingLabel`: null is the honest answer whenever the start was not
|
||||||
|
// witnessed here, which is what opening a session that is already compacting looks like.
|
||||||
|
var compactingSince by remember { mutableStateOf<Long?>(null) }
|
||||||
|
var compactingFor by remember { mutableStateOf<Long?>(null) }
|
||||||
var streamError by remember { mutableStateOf<String?>(null) }
|
var streamError by remember { mutableStateOf<String?>(null) }
|
||||||
var actionError by remember { mutableStateOf<String?>(null) }
|
var actionError by remember { mutableStateOf<String?>(null) }
|
||||||
var input by remember { mutableStateOf("") }
|
var input by remember { mutableStateOf("") }
|
||||||
@@ -276,6 +292,10 @@ fun SessionScreen(
|
|||||||
// Which runs of adjacent tool calls are open. Keyed by the first call's
|
// Which runs of adjacent tool calls are open. Keyed by the first call's
|
||||||
// id, so a group survives more calls arriving after it.
|
// id, so a group survives more calls arriving after it.
|
||||||
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
|
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
|
||||||
|
// Which messages from other agents are open, by the seq that identifies their row. Closed
|
||||||
|
// by default, which is the rule for anything new in this transcript: a screen that opens
|
||||||
|
// everything it can is one nobody can scan.
|
||||||
|
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
|
||||||
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
|
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
|
||||||
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
|
||||||
@@ -326,7 +346,20 @@ fun SessionScreen(
|
|||||||
when (val event = entry.event) {
|
when (val event = entry.event) {
|
||||||
is SessionEvent.UsageDelta -> totalTokens += event.tokens
|
is SessionEvent.UsageDelta -> totalTokens += event.tokens
|
||||||
else -> {
|
else -> {
|
||||||
if (event is SessionEvent.Status) status = event.state
|
if (event is SessionEvent.Status) {
|
||||||
|
// Started here, or nowhere. `ready` is what separates the live stream from
|
||||||
|
// the page of history the screen opens with, and a compaction found in that
|
||||||
|
// page began before anybody here was watching -- timing it from now would
|
||||||
|
// report the moment we arrived as the moment it started.
|
||||||
|
compactingSince =
|
||||||
|
when {
|
||||||
|
event.state != "compacting" -> null
|
||||||
|
status == "compacting" -> compactingSince
|
||||||
|
ready -> SystemClock.elapsedRealtime()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
status = event.state
|
||||||
|
}
|
||||||
// The message coming back is the session saying it has
|
// The message coming back is the session saying it has
|
||||||
// read it, so the bubble held below the indicator becomes
|
// read it, so the bubble held below the indicator becomes
|
||||||
// the row `foldEvent` is about to add. Matched by text --
|
// the row `foldEvent` is about to add. Matched by text --
|
||||||
@@ -344,6 +377,22 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A compaction reports nothing about its own progress -- measured against the CLI, which
|
||||||
|
// says it has started, and then says nothing at all until it is done. So what this counts is
|
||||||
|
// the one thing anybody here can measure: how long it has been going. A bar filling up would
|
||||||
|
// be this screen inventing the part the CLI does not send.
|
||||||
|
LaunchedEffect(compactingSince) {
|
||||||
|
val since = compactingSince
|
||||||
|
if (since == null) {
|
||||||
|
compactingFor = null
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
compactingFor = (SystemClock.elapsedRealtime() - since) / 1000
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The stream lifecycle: connect, follow, and on any drop reconnect
|
// The stream lifecycle: connect, follow, and on any drop reconnect
|
||||||
// from the cursor -- so a flaky link (or a backend restart) costs
|
// from the cursor -- so a flaky link (or a backend restart) costs
|
||||||
// nothing but the gap's latency.
|
// nothing but the gap's latency.
|
||||||
@@ -423,18 +472,29 @@ fun SessionScreen(
|
|||||||
// stays started.
|
// stays started.
|
||||||
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
||||||
|
|
||||||
// Whether the view is pinned to the newest message. The list is laid
|
// Whether the newest message is on screen right now. The list is laid out from the bottom
|
||||||
// out from the bottom (see the LazyColumn below), so "newest" is index
|
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
|
||||||
// 0 and being pinned is simply being at the start of it.
|
// it. This is what the jump-to-newest button watches: it is about what the reader can see.
|
||||||
//
|
val atNewest by remember {
|
||||||
// Read from the scroll rather than remembered as a flag: with the list
|
|
||||||
// anchored this way there is no moment where new content pushes the
|
|
||||||
// anchor away, so there is nothing to protect a remembered value from.
|
|
||||||
val followTail by remember {
|
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Whether they *chose* to be there, which is a different question and the one that decides
|
||||||
|
// whether an arriving message brings the view with it.
|
||||||
|
//
|
||||||
|
// Remembered, and only ever written when a scroll settles -- so it records where the reader
|
||||||
|
// last left the list, and an insertion cannot change the answer. Reading the live position
|
||||||
|
// instead looks right and is subtly wrong: a keyed list moves its anchor to keep the reader's
|
||||||
|
// content still, so by the time the new item can be observed the view is already one item
|
||||||
|
// away from the newest and reports itself as scrolled back. The message then never followed,
|
||||||
|
// which was visible as a compaction whose progress bar sat just off the bottom of the screen
|
||||||
|
// while the button that started it said it was running.
|
||||||
|
var followTail by remember { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(listState) {
|
||||||
|
snapshotFlow { listState.isScrollInProgress }
|
||||||
|
.collect { scrolling -> if (!scrolling) followTail = atNewest }
|
||||||
|
}
|
||||||
// A new item at the newest end shifts every index by one, so the view
|
// A new item at the newest end shifts every index by one, so the view
|
||||||
// has to step back to 0 to stay put. One item, instantly -- not a
|
// has to step back to 0 to stay put. One item, instantly -- not a
|
||||||
// journey through the transcript.
|
// journey through the transcript.
|
||||||
@@ -443,8 +503,15 @@ fun SessionScreen(
|
|||||||
// from one line to four and the keyboard opens under it, and neither
|
// from one line to four and the keyboard opens under it, and neither
|
||||||
// is a new message, so watching the item count alone leaves the newest
|
// is a new message, so watching the item count alone leaves the newest
|
||||||
// text drifting out of sight while somebody writes a reply to it.
|
// text drifting out of sight while somebody writes a reply to it.
|
||||||
|
//
|
||||||
|
// Counted in list items rather than in transcript rows, because the rows are not all of it:
|
||||||
|
// the working indicator and a queued message are items too, and they arrive at exactly the
|
||||||
|
// same end. Sibling to the paging trigger below, which is the same count read from the other
|
||||||
|
// end for the same reason.
|
||||||
LaunchedEffect(listState) {
|
LaunchedEffect(listState) {
|
||||||
snapshotFlow { Pair(items.size, listState.layoutInfo.viewportSize.height) }
|
snapshotFlow {
|
||||||
|
Pair(listState.layoutInfo.totalItemsCount, listState.layoutInfo.viewportSize.height)
|
||||||
|
}
|
||||||
.collect { (count, _) -> if (followTail && count > 0) listState.scrollToItem(0) }
|
.collect { (count, _) -> if (followTail && count > 0) listState.scrollToItem(0) }
|
||||||
}
|
}
|
||||||
// Reaching the far end of what is loaded -- the oldest item, which in
|
// Reaching the far end of what is loaded -- the oldest item, which in
|
||||||
@@ -658,17 +725,24 @@ fun SessionScreen(
|
|||||||
// where the newest message is.
|
// where the newest message is.
|
||||||
if (running) {
|
if (running) {
|
||||||
item(key = "indicator") {
|
item(key = "indicator") {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
// Two shapes for two kinds of busy: an ordinary turn is a spinner beside
|
||||||
CircularProgressIndicator(
|
// a word, because the answer it is producing appears directly below it,
|
||||||
modifier = Modifier.width(14.dp).height(14.dp),
|
// and a compaction takes the whole row because nothing else will.
|
||||||
strokeWidth = 2.dp,
|
if (status == "compacting") {
|
||||||
)
|
CompactingRow(compactingFor)
|
||||||
Spacer(Modifier.width(8.dp))
|
} else {
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (status == "compacting") "compacting" else "working",
|
CircularProgressIndicator(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
modifier = Modifier.width(14.dp).height(14.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"working",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (status == "exited") {
|
} else if (status == "exited") {
|
||||||
@@ -768,6 +842,17 @@ fun SessionScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
is TranscriptItem.CompactedNote -> CompactedRow(item)
|
is TranscriptItem.CompactedNote -> CompactedRow(item)
|
||||||
|
is TranscriptItem.PeerNote ->
|
||||||
|
PeerMessageRow(
|
||||||
|
item = item,
|
||||||
|
expanded = item.seq in expandedNotes,
|
||||||
|
onToggle = {
|
||||||
|
expandedNotes =
|
||||||
|
if (item.seq in expandedNotes)
|
||||||
|
expandedNotes - item.seq
|
||||||
|
else expandedNotes + item.seq
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -783,7 +868,7 @@ fun SessionScreen(
|
|||||||
// which is where this goes. The name is carried in the
|
// which is where this goes. The name is carried in the
|
||||||
// description, since an arrow alone says nothing to a screen
|
// description, since an arrow alone says nothing to a screen
|
||||||
// reader and nothing to whoever finds this in six months.
|
// reader and nothing to whoever finds this in six months.
|
||||||
if (!followTail) {
|
if (!atNewest) {
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { scope.launch { listState.animateScrollToItem(0) } },
|
onClick = { scope.launch { listState.animateScrollToItem(0) } },
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ val runningColor: Color
|
|||||||
val failedColor: Color
|
val failedColor: Color
|
||||||
@Composable get() = MaterialTheme.colorScheme.error
|
@Composable get() = MaterialTheme.colorScheme.error
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Working on the conversation rather than in it: a compaction.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
val compactingColor: Color
|
||||||
|
@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. */
|
||||||
val awaitingColor: Color
|
val awaitingColor: Color
|
||||||
@Composable get() = Mocha.Peach
|
@Composable get() = Mocha.Peach
|
||||||
|
|||||||
@@ -732,6 +732,26 @@ fn translate_line(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A turn nobody here started -- see `proves_a_turn`. Said before
|
||||||
|
// the event that proves it, for the same reason a steer is: the
|
||||||
|
// session was already working when it produced this.
|
||||||
|
let started = {
|
||||||
|
let mut queue = queue.lock().unwrap();
|
||||||
|
let started = proves_a_turn(&event) && !queue.running && !queue.closed;
|
||||||
|
if started {
|
||||||
|
queue.running = true;
|
||||||
|
}
|
||||||
|
started
|
||||||
|
};
|
||||||
|
if started
|
||||||
|
&& sink
|
||||||
|
.send(Event::Status {
|
||||||
|
state: SessionStatus::Running,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
event,
|
event,
|
||||||
Event::Status {
|
Event::Status {
|
||||||
@@ -747,6 +767,39 @@ fn translate_line(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this event could only have come from a turn in flight.
|
||||||
|
///
|
||||||
|
/// The turn this side starts is announced where it is started, and that
|
||||||
|
/// covers the common case and nothing else. Everything below happens
|
||||||
|
/// without a phone asking for it: a compaction the CLI decided on by
|
||||||
|
/// itself, a session adopted while it was already mid-turn, a message
|
||||||
|
/// that reached the conversation by some route other than this server --
|
||||||
|
/// another agent writing to it, or somebody at the terminal. In all of
|
||||||
|
/// them the CLI is plainly working and the only thing that would ever
|
||||||
|
/// have said so is a `Running` nobody sent, so the session sits there
|
||||||
|
/// reading as idle until the turn ends.
|
||||||
|
///
|
||||||
|
/// So the driver says it from what it observes rather than from what it
|
||||||
|
/// was asked to do, and this is the same set as [`announces_a_steer`]
|
||||||
|
/// with the ends swapped: that one takes the `Idle` that closes a turn
|
||||||
|
/// and this one takes the states that open one. `Idle` is the pair to
|
||||||
|
/// this -- it is where `running` goes back to false, a few lines above
|
||||||
|
/// where it is set here.
|
||||||
|
fn proves_a_turn(event: &Event) -> bool {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
Event::AssistantText { .. }
|
||||||
|
| Event::ToolStart { .. }
|
||||||
|
| Event::ToolUpdate { .. }
|
||||||
|
| Event::ToolEnd { .. }
|
||||||
|
| Event::Question { .. }
|
||||||
|
| Event::Compacted { .. }
|
||||||
|
| Event::Status {
|
||||||
|
state: SessionStatus::Compacting | SessionStatus::AwaitingInput
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether this event proves the CLI has consumed anything written to it
|
/// Whether this event proves the CLI has consumed anything written to it
|
||||||
/// since the last one did.
|
/// since the last one did.
|
||||||
///
|
///
|
||||||
@@ -985,6 +1038,73 @@ mod tests {
|
|||||||
assert!(queue.closed);
|
assert!(queue.closed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_turn_this_side_did_not_start_still_reports_as_running() {
|
||||||
|
// The case: a session picked up while it was already working, or
|
||||||
|
// one another agent wrote to. Nothing called `send_user_message`,
|
||||||
|
// so the only thing that can say the session is busy is what it
|
||||||
|
// is observed doing.
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let (sink, mut received) = mpsc::unbounded_channel();
|
||||||
|
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf())));
|
||||||
|
let queue = Arc::new(Mutex::new(Queue::default()));
|
||||||
|
|
||||||
|
let text = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"working"}},"parent_tool_use_id":null}"#;
|
||||||
|
assert!(translate_line(text, dir.path(), &state, &sink, &queue));
|
||||||
|
assert_eq!(
|
||||||
|
received.try_recv().ok(),
|
||||||
|
Some(Event::Status {
|
||||||
|
state: SessionStatus::Running
|
||||||
|
}),
|
||||||
|
"a turn in flight has to be reported before the output proving it"
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
received.try_recv().ok(),
|
||||||
|
Some(Event::AssistantText { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
// Once only: the turn is known to be running now, and a status per
|
||||||
|
// delta would be a status per word.
|
||||||
|
assert!(translate_line(text, dir.path(), &state, &sink, &queue));
|
||||||
|
assert!(matches!(
|
||||||
|
received.try_recv().ok(),
|
||||||
|
Some(Event::AssistantText { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
// And the end of the turn puts it back, so the next one is
|
||||||
|
// reported the same way.
|
||||||
|
let done = r#"{"type":"result","subtype":"success","is_error":false,"usage":{}}"#;
|
||||||
|
assert!(translate_line(done, dir.path(), &state, &sink, &queue));
|
||||||
|
assert_eq!(
|
||||||
|
received.try_recv().ok(),
|
||||||
|
Some(Event::Status {
|
||||||
|
state: SessionStatus::Idle
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(!queue.lock().unwrap().running);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_from_a_process_that_has_gone_does_not_revive_the_turn() {
|
||||||
|
// `close` is what says the process is gone and reports the
|
||||||
|
// messages that died with it. Anything still in the pipe after
|
||||||
|
// that must not put the session back to work, because there is
|
||||||
|
// nothing left to do the work.
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let (sink, mut received) = mpsc::unbounded_channel();
|
||||||
|
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf())));
|
||||||
|
let queue = Arc::new(Mutex::new(Queue::default()));
|
||||||
|
queue.lock().unwrap().close(&sink, "the session ended");
|
||||||
|
|
||||||
|
let text = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"late"}},"parent_tool_use_id":null}"#;
|
||||||
|
assert!(translate_line(text, dir.path(), &state, &sink, &queue));
|
||||||
|
assert!(matches!(
|
||||||
|
received.try_recv().ok(),
|
||||||
|
Some(Event::AssistantText { .. })
|
||||||
|
));
|
||||||
|
assert!(!queue.lock().unwrap().running);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn closing_an_empty_queue_says_nothing() {
|
fn closing_an_empty_queue_says_nothing() {
|
||||||
let (sink, mut received) = mpsc::unbounded_channel();
|
let (sink, mut received) = mpsc::unbounded_channel();
|
||||||
|
|||||||
@@ -104,6 +104,19 @@ pub enum Event {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
about: Option<String>,
|
about: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// A message another agent sent this session.
|
||||||
|
///
|
||||||
|
/// Its own kind rather than a `UserMessage`, because it is not
|
||||||
|
/// something the reader said and a transcript that renders it in their
|
||||||
|
/// voice is claiming they did. It also explains what would otherwise
|
||||||
|
/// be inexplicable: a session that starts working on something nobody
|
||||||
|
/// on this phone asked for.
|
||||||
|
PeerMessage {
|
||||||
|
/// The sending session's own name, which is what the reader
|
||||||
|
/// recognises it by -- the socket path it came from is not.
|
||||||
|
from: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
/// The manager's record of a question being answered, so a rendered
|
/// The manager's record of a question being answered, so a rendered
|
||||||
/// question card resolves on every device, not just the one that
|
/// question card resolves on every device, not just the one that
|
||||||
/// answered it.
|
/// answered it.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
//! - `/slow [seconds]` -- a turn that stays running (default 30), so states that only
|
//! - `/slow [seconds]` -- a turn that stays running (default 30), so states that only
|
||||||
//! exist *while* something is happening can be looked at.
|
//! exist *while* something is happening can be looked at.
|
||||||
//! - `/error [text]` -- a failure, which is otherwise awkward to cause.
|
//! - `/error [text]` -- a failure, which is otherwise awkward to cause.
|
||||||
|
//! - `/peer [text]` -- a message from another agent, which otherwise takes
|
||||||
|
//! two live sessions and one of them deciding to write.
|
||||||
//!
|
//!
|
||||||
//! This is exactly the event vocabulary the real drivers produce, so a UI
|
//! This is exactly the event vocabulary the real drivers produce, so a UI
|
||||||
//! that renders echo sessions correctly renders the real thing.
|
//! that renders echo sessions correctly renders the real thing.
|
||||||
@@ -36,11 +38,14 @@ use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus};
|
|||||||
/// stay fast.
|
/// stay fast.
|
||||||
const DELTA_DELAY: Duration = Duration::from_millis(50);
|
const DELTA_DELAY: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
/// How long a fake compaction takes. A real one runs for a minute or two,
|
/// How long a fake compaction takes.
|
||||||
/// which is too long to sit through when what is being checked is what the
|
///
|
||||||
/// screen does; this is long enough that the state is visible and short
|
/// A measured one, near enough: driving a real session through `/compact`
|
||||||
/// enough to wait for.
|
/// on 2026-08-29 took 13 seconds for a small conversation, and a large one
|
||||||
const COMPACT_TIME: Duration = Duration::from_secs(3);
|
/// takes minutes. Three seconds -- what this was -- is too short to look
|
||||||
|
/// at the row that only exists while a compaction is running, and too
|
||||||
|
/// short to watch its elapsed count reach two digits.
|
||||||
|
const COMPACT_TIME: Duration = Duration::from_secs(13);
|
||||||
|
|
||||||
pub struct EchoDriver {
|
pub struct EchoDriver {
|
||||||
sink: EventSink,
|
sink: EventSink,
|
||||||
@@ -115,6 +120,28 @@ impl Driver for EchoDriver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Answered on the spot rather than in the turn below, because a
|
||||||
|
// peer message is not a turn: it is something that arrives, and
|
||||||
|
// what is being exercised is the row it becomes. The message that
|
||||||
|
// asked for it is still announced -- every driver owes exactly one
|
||||||
|
// `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() });
|
||||||
|
self.emit(Event::PeerMessage {
|
||||||
|
from: "dev-updater-f5".to_string(),
|
||||||
|
text: if rest.trim().is_empty() {
|
||||||
|
"Pull before you touch AGENTS.md -- I pushed three commits to it \
|
||||||
|
in the last hour, and origin/main has moved since you last looked.\n\n\
|
||||||
|
The tree is clean as of now, but it was not for most of that time."
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
rest.trim().to_string()
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(rest) = text.strip_prefix("/question") {
|
if let Some(rest) = text.strip_prefix("/question") {
|
||||||
let id = format!("q-{}", super::random_hex());
|
let id = format!("q-{}", super::random_hex());
|
||||||
let prompt = if rest.trim().is_empty() {
|
let prompt = if rest.trim().is_empty() {
|
||||||
|
|||||||
@@ -410,13 +410,28 @@ pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> {
|
|||||||
/// which reads its own session file.
|
/// which reads its own session file.
|
||||||
pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
|
pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
// What the newest record that had an opinion says the session is
|
||||||
|
// doing. Kept to the end rather than pushed as it is found, because
|
||||||
|
// the answer is the last one and everything before it is history.
|
||||||
|
let mut state = None;
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
let Ok(record) = serde_json::from_str::<Value>(line) else {
|
let Ok(record) = serde_json::from_str::<Value>(line) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if let Some(peer) = peer_message(&record) {
|
||||||
|
// Before `is_hidden`, which these records are: the CLI marks
|
||||||
|
// them meta because they are not the user's own words, and
|
||||||
|
// that is the reason to draw them differently rather than the
|
||||||
|
// reason to drop them. A session working on something a phone
|
||||||
|
// never asked for is otherwise unexplainable from the phone.
|
||||||
|
state = turn_state(&record).or(state);
|
||||||
|
events.push(peer);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if is_hidden(&record) {
|
if is_hidden(&record) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
state = turn_state(&record).or(state);
|
||||||
let Some(message) = record.get("message") else {
|
let Some(message) = record.get("message") else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -429,9 +444,72 @@ pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(state) = state {
|
||||||
|
events.push(Event::Status { state });
|
||||||
|
}
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A message from another agent, as the CLI records one.
|
||||||
|
///
|
||||||
|
/// Measured from a real session file (2026-08-29): the record is a `user`
|
||||||
|
/// one marked `isMeta`, and its `origin` carries `kind: "peer"`, the
|
||||||
|
/// sending session's `name`, and the message itself as `body`. The
|
||||||
|
/// message content beside it is the same text wrapped in an explanatory
|
||||||
|
/// preamble and a `<cross-session-message>` tag, which is written for the
|
||||||
|
/// model that has to read it rather than for a person -- so the body is
|
||||||
|
/// what a reader is shown, and the name is who they are told sent it.
|
||||||
|
fn peer_message(record: &Value) -> Option<Event> {
|
||||||
|
let origin = record.get("origin")?;
|
||||||
|
if origin.get("kind").and_then(Value::as_str) != Some("peer") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Event::PeerMessage {
|
||||||
|
from: origin
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("another session")
|
||||||
|
.to_string(),
|
||||||
|
text: origin.get("body").and_then(Value::as_str)?.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this record means the session is working, as far as it can be
|
||||||
|
/// told from the file.
|
||||||
|
///
|
||||||
|
/// The one thing a session file does not contain is the CLI saying "this
|
||||||
|
/// turn is over": there is no `result` record, only the messages. What
|
||||||
|
/// there is instead is why the last assistant message stopped, and that
|
||||||
|
/// answers it -- `tool_use` means a call is being made and more is coming,
|
||||||
|
/// anything else means the model has finished talking. Anything on the
|
||||||
|
/// user's side of the conversation -- a person, a tool's result, another
|
||||||
|
/// agent -- means the session has something to answer and is answering it.
|
||||||
|
///
|
||||||
|
/// `None` is the third answer and it matters: a record that says nothing
|
||||||
|
/// about the turn leaves the status alone rather than voting for idle. The
|
||||||
|
/// same goes for a record whose reason for stopping is missing, which is
|
||||||
|
/// what a future CLI adding a shape we do not know looks like.
|
||||||
|
///
|
||||||
|
/// What this cannot see is a session that stopped existing mid-turn -- its
|
||||||
|
/// file's last record still says `tool_use`, so it reads as working
|
||||||
|
/// forever. Nothing in the file distinguishes that from a model thinking,
|
||||||
|
/// and inventing a timeout here would replace a stale reading with a
|
||||||
|
/// confident wrong one.
|
||||||
|
fn turn_state(record: &Value) -> Option<super::driver::SessionStatus> {
|
||||||
|
use super::driver::SessionStatus;
|
||||||
|
match record.get("type").and_then(Value::as_str)? {
|
||||||
|
"user" => Some(SessionStatus::Running),
|
||||||
|
"assistant" => match record["message"]
|
||||||
|
.get("stop_reason")
|
||||||
|
.and_then(Value::as_str)?
|
||||||
|
{
|
||||||
|
"tool_use" => Some(SessionStatus::Running),
|
||||||
|
_ => Some(SessionStatus::Idle),
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::Path) {
|
fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::Path) {
|
||||||
// A tool result arrives as a user record, because that is how the API
|
// A tool result arrives as a user record, because that is how the API
|
||||||
// models it -- but it is the other half of a tool call, not something
|
// models it -- but it is the other half of a tool call, not something
|
||||||
@@ -678,8 +756,83 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"plain output"}]}}"#;
|
let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"plain output"}]}}"#;
|
||||||
let events = events_from(line, dir.path());
|
let events = events_from(line, dir.path());
|
||||||
assert_eq!(events.len(), 1, "{events:?}");
|
// The result, and the turn state it implies: a tool has answered,
|
||||||
|
// so the model is about to be asked again.
|
||||||
|
assert_eq!(events.len(), 2, "{events:?}");
|
||||||
|
assert_eq!(
|
||||||
|
events[1],
|
||||||
|
Event::Status {
|
||||||
|
state: super::super::driver::SessionStatus::Running
|
||||||
|
}
|
||||||
|
);
|
||||||
// No stray directory for a session that never produced one.
|
// No stray directory for a session that never produced one.
|
||||||
assert!(!dir.path().join("files").exists());
|
assert!(!dir.path().join("files").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_message_from_another_agent_is_kept_and_named() {
|
||||||
|
// The real shape, from a session file: the CLI marks these meta,
|
||||||
|
// and everything a reader needs is in `origin`.
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let line = r#"{"type":"user","isMeta":true,"origin":{"kind":"peer","from":"uds:/run/user/1000/cc-socks/605.sock","verifiedPeerPid":605,"name":"dev-updater-f5","fromMode":"prompting","body":"Pull before you touch AGENTS.md."},"message":{"role":"user","content":"Another Claude session sent a message:\n<cross-session-message from-name=\"dev-updater-f5\">\nPull before you touch AGENTS.md.\n</cross-session-message>"}}"#;
|
||||||
|
let events = events_from(line, dir.path());
|
||||||
|
assert_eq!(
|
||||||
|
events[0],
|
||||||
|
Event::PeerMessage {
|
||||||
|
from: "dev-updater-f5".to_string(),
|
||||||
|
// The body, not the wrapper the model is given.
|
||||||
|
text: "Pull before you touch AGENTS.md.".to_string(),
|
||||||
|
},
|
||||||
|
"{events:?}"
|
||||||
|
);
|
||||||
|
// And it counts as the session having been given something.
|
||||||
|
assert_eq!(
|
||||||
|
events[1],
|
||||||
|
Event::Status {
|
||||||
|
state: super::super::driver::SessionStatus::Running
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_last_record_says_whether_the_session_is_working() {
|
||||||
|
use super::super::driver::SessionStatus;
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let asked = r#"{"type":"user","message":{"role":"user","content":"do the thing"}}"#;
|
||||||
|
let calling = r#"{"type":"assistant","message":{"role":"assistant","stop_reason":"tool_use","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}"#;
|
||||||
|
let done = r#"{"type":"assistant","message":{"role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"done"}]}}"#;
|
||||||
|
|
||||||
|
let state = |text: &str| {
|
||||||
|
events_from(text, dir.path())
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.find_map(|event| match event {
|
||||||
|
Event::Status { state } => Some(state),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
assert_eq!(state(asked), Some(SessionStatus::Running));
|
||||||
|
assert_eq!(
|
||||||
|
state(&[asked, calling].join("\n")),
|
||||||
|
Some(SessionStatus::Running)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state(&[asked, calling, done].join("\n")),
|
||||||
|
Some(SessionStatus::Idle),
|
||||||
|
"a turn that has finished talking is over"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A subagent's own messages are not the session's turn, and a
|
||||||
|
// record with no stop reason is not an answer -- neither may
|
||||||
|
// overrule what the conversation itself last said.
|
||||||
|
let sidechain = r#"{"type":"assistant","isSidechain":true,"message":{"role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"sub"}]}}"#;
|
||||||
|
let unknown = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"?"}]}}"#;
|
||||||
|
assert_eq!(
|
||||||
|
state(&[asked, calling, sidechain, unknown].join("\n")),
|
||||||
|
Some(SessionStatus::Running)
|
||||||
|
);
|
||||||
|
|
||||||
|
// And nothing at all to go on says nothing, rather than idle.
|
||||||
|
assert_eq!(state(r#"{"type":"summary","summary":"x"}"#), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1016,6 +1016,18 @@ async fn pump(
|
|||||||
Event::MessageTaken { text } => Event::UserMessage { text },
|
Event::MessageTaken { text } => Event::UserMessage { text },
|
||||||
other => other,
|
other => other,
|
||||||
};
|
};
|
||||||
|
// A status the session is already in is not news. Imported
|
||||||
|
// sessions make this the common case rather than a rarity: each
|
||||||
|
// sync reads the turn state off the file's newest record, and
|
||||||
|
// most of them find the same answer as the sync before -- which
|
||||||
|
// would otherwise be a transcript entry, a broadcast, and a
|
||||||
|
// recomposition on every phone, several times a minute, to say
|
||||||
|
// nothing at all.
|
||||||
|
if let Event::Status { state } = &event
|
||||||
|
&& *shared.status.lock().unwrap() == *state
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match transcript.append(event, ts) {
|
match transcript.append(event, ts) {
|
||||||
Ok(entry) => {
|
Ok(entry) => {
|
||||||
if let Event::Status { state } = &entry.event {
|
if let Event::Status { state } = &entry.event {
|
||||||
|
|||||||
Reference in new issue
Block a user