Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt
T
iris 47d6b84265 Report what a session last did and what it cost, not what this page holds
Three readings that were each a part presented as the whole.

**"just now", everywhere, after a restart.** A relaunched session took its
last-activity from the clock, so every session the backend brought back
claimed to have been active that instant. On the phone that is every row
reading "just now" and the list -- which sorts by it -- coming back in an
order that means nothing, with the conversation somebody was in the middle
of buried among sessions untouched for days. It comes from the transcript
now, in the pass `Transcript::open` already makes, which is the same
correction `last_status` got and for the same reason: a server that has just
started has been told nothing, and the file is the only thing it knows. The
test backdates a transcript by a day, so it cannot pass by the test being
fast; it fails on the old code with the clock's answer in the message.

**The token total was the newest page's.** The phone added up the
`UsageDelta`s it had received, and it opens a session on the newest page of
the transcript -- so a long conversation reported its last few turns as the
total, and a page with no turn in it reported nothing at all, since zero is
drawn as blank. That is the reading Bryan saw: no tokens, on sessions that
had certainly spent some.

The count belongs to the server, which is the only side that sees every
turn. `UsageDelta` now carries the running total beside the delta, filled in
by the pump rather than by each driver -- a driver knows what its own turn
cost and nothing else does, so a new one cannot get this wrong by leaving it
out -- and the session row reports it for a screen that has not opened the
stream yet. The phone takes the largest total it has seen instead of
accumulating, which also means paging older history cannot move it, and
leaves the seeded figure alone for transcripts recorded before the field
existed. Seeded by summing deltas at startup for exactly that reason.

**The header said the model twice and the machine backwards.** A session's
subtitle now reads `machine · provider`, in that order and with no "on"
joining them, matching the list and the usage dialog -- the "on" made it a
phrase, which works in one order and stops working the moment the same pair
is shown somewhere else. The model is gone from it: the footer's picker
already shows what the session is set to, and two places showing it meant
two things to keep in step, which disagreed for a moment on every switch
since one follows the request and the other the session's own answer.

Checked on the emulator against a twelve-turn session whose visible page
held the last six: the header reads "this machine · echo", the status row
reads "idle", and the total reads 42 tok, which is what `GET
/sessions/{id}` says rather than what the page adds up to.
2026-08-29 23:52:58 -04:00

225 lines
9.9 KiB
Kotlin

package com.example.aiapp
import org.json.JSONObject
// The common event model, mirrored from server/src/session/driver.rs --
// the app renders purely from this stream (replayed from the transcript by
// cursor, then live), so there is no separate "load history" shape to keep
// in sync with it.
/** One transcript line: the event plus its resume cursor and time. */
data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
/**
* One choice offered in answer to a question.
*
* More than a label because the reader is deciding rather than confirming: what an option means,
* and what picking it would produce, are the things that decide it. Both are absent on a
* permission, whose Allow and Deny mean exactly what they say.
*/
data class QuestionOption(val label: String, val description: String?, val preview: String?)
sealed class SessionEvent {
data class UserMessage(
val text: String,
/**
* The [MessageQueued] this resolves, or null when it never waited.
*
* Matched on rather than the text, because the same message sent twice is two waiting
* bubbles and clearing whichever one matched first would leave the wrong one on screen.
*/
val id: String?,
) : SessionEvent()
/**
* A message the server has accepted and the session has not read yet.
*
* From the server, not from this app's memory of what it sent. The pending bubble used to be
* screen state, so leaving the session or restarting the app drew nothing waiting while the
* message was still queued -- and nothing waiting is what "there is nothing" looks like.
*
* Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by
* [CommandSent].
*/
data class MessageQueued(val id: String, val text: String) : SessionEvent()
data class AssistantText(val delta: String) : SessionEvent()
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
data class ToolUpdate(val id: String, val output: String) : SessionEvent()
data class ToolEnd(val id: String, val output: String) : SessionEvent()
data class Image(
val ref: String,
/** The tool call whose result carried it, or null for a person's own attachment. */
val about: String?,
) : SessionEvent()
data class Question(
val id: String,
val prompt: String,
/** A few words naming what the question is about, when the asker offered one. */
val header: String?,
val options: List<QuestionOption>,
/** Whether several options may be chosen at once. */
val multiSelect: Boolean,
/** The tool call this is permission for, or null when it is not about one. */
val about: String?,
) : SessionEvent()
/** Everything chosen for one question, in the order it was offered. */
data class Answered(val id: String, val answers: List<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()
/**
* 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()
/**
* What the session is set to, as the session itself reports it.
*
* Either field alone: the two are confirmed separately and by different things. Asking for a
* change is not having one, so this -- not the request -- is what the pickers show.
*/
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
/**
* What a turn cost, and what the session has cost in total.
*
* [total] is the server's running figure, carried on the event so a reader never adds up its
* own: a phone opens a session on the newest page of the transcript, so a sum it computed would
* be that page's share of the conversation wearing the whole conversation's label. Zero on
* entries recorded before the backend sent it.
*/
data class UsageDelta(val tokens: Long, val total: Long) : SessionEvent()
/**
* A compaction that finished, and how much context it recovered.
*
* The counts are nullable because the server sends them only when it was told them: a
* compaction whose size nobody measured has to be able to say so, since a zero here would read
* as "recovered nothing" and a made-up number would read as a measurement.
*/
data class Compacted(
val preTokens: Long?,
val postTokens: Long?,
/** What asked for it, in the CLI's own word; `auto` is the one worth naming. */
val trigger: String?,
) : SessionEvent()
/**
* The conversation was cleared. Everything above this is still here to read and is no longer in
* the session's context.
*
* An object rather than a class because it carries nothing: what it means is entirely its
* position in the transcript.
*/
data object Cleared : SessionEvent()
data class Error(val message: String) : SessionEvent()
/**
* An event type this app build doesn't know -- a newer server. Kept (not thrown) so one new
* event kind degrades to a placeholder row instead of killing the stream.
*/
data class Unknown(val type: String) : SessionEvent()
}
fun parseSeqEvent(json: String): SeqEvent {
val body = JSONObject(json)
val event =
when (val type = body.getString("type")) {
"userMessage" ->
SessionEvent.UserMessage(
body.getString("text"),
body.optString("id").ifEmpty { null },
)
"messageQueued" ->
SessionEvent.MessageQueued(body.getString("id"), body.getString("text"))
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"toolStart" ->
SessionEvent.ToolStart(
id = body.getString("id"),
tool = body.getString("tool"),
// Kept as raw JSON text: the input shape is the tool's own
// business, and the UI only ever shows it verbatim.
input = body.get("input").toString(),
)
"toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output"))
"toolEnd" -> SessionEvent.ToolEnd(body.getString("id"), body.getString("output"))
"image" ->
SessionEvent.Image(
ref = body.getString("ref"),
about = body.optString("about").ifEmpty { null },
)
"question" ->
SessionEvent.Question(
id = body.getString("id"),
prompt = body.getString("prompt"),
header = body.optString("header").ifEmpty { null },
options =
body.getJSONArray("options").let { options ->
(0 until options.length()).map { at ->
val option = options.getJSONObject(at)
QuestionOption(
label = option.getString("label"),
description = option.optString("description").ifEmpty { null },
preview = option.optString("preview").ifEmpty { null },
)
}
},
multiSelect = body.optBoolean("multiSelect", false),
about = body.optString("about").ifEmpty { null },
)
"answered" ->
SessionEvent.Answered(
body.getString("id"),
body.getJSONArray("answers").let { answers ->
(0 until answers.length()).map { answers.getString(it) }
},
)
"peerMessage" ->
SessionEvent.PeerMessage(body.getString("from"), body.getString("text"))
"commandQueued" ->
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
"status" -> SessionEvent.Status(body.getString("state"))
"settings" ->
SessionEvent.Settings(
model = body.optString("model").ifEmpty { null },
permissionMode = body.optString("permissionMode").ifEmpty { null },
)
"usageDelta" ->
SessionEvent.UsageDelta(body.getLong("tokens"), body.optLong("total", 0))
"compacted" ->
SessionEvent.Compacted(
preTokens = if (body.has("preTokens")) body.getLong("preTokens") else null,
postTokens = if (body.has("postTokens")) body.getLong("postTokens") else null,
trigger = body.optString("trigger").ifEmpty { null },
)
"cleared" -> SessionEvent.Cleared
"error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type)
}
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
}