From 71481ac2ff85daede80dd458ffe85234c4e38df3 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Mon, 9 Nov 2026 16:45:00 -0500
Subject: [PATCH] feat(agent): an Agent tab in the launcher that talks to
Claude Code outside any session
---
app/src/main/AndroidManifest.xml | 1 +
.../com/droiddeck/launcher/agent/AgentHost.kt | 230 ++++++++++++++++++
.../launcher/agent/AgentHostService.kt | 51 ++++
.../com/droiddeck/launcher/ui/AgentPage.kt | 113 +++++++++
.../droiddeck/launcher/ui/FrontEndContent.kt | 4 +
.../com/droiddeck/launcher/ui/FrontEndRail.kt | 8 +-
app/src/main/res/values/strings.xml | 8 +
.../droiddeck/launcher/agent/AgentHostTest.kt | 57 +++++
8 files changed, 470 insertions(+), 2 deletions(-)
create mode 100644 app/src/main/java/com/droiddeck/launcher/agent/AgentHost.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/agent/AgentHostService.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d5bb0dc0d..82839c09d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -302,6 +302,7 @@
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density"
android:theme="@style/SessionTheme" />
+
()
+ var state by mutableStateOf(State.IDLE)
+ private set
+ var installed by mutableStateOf(false)
+ private set
+
+ private val main = Handler(Looper.getMainLooper())
+ private var process: Process? = null
+ private var input: Writer? = null
+ private var loaded = false
+
+ fun refresh(context: Context) {
+ val claude = File(LinuxRuntime.rootDir(context), CLAUDE.removePrefix("/"))
+ installed = claude.exists() || Files.isSymbolicLink(claude.toPath())
+ if (!installed) state = State.MISSING else if (state == State.MISSING) state = State.IDLE
+ if (!loaded) {
+ loaded = true
+ entries.addAll(readTranscript(transcript(context)))
+ }
+ }
+
+ fun send(context: Context, text: String) {
+ val app = context.applicationContext
+ val message = text.trim()
+ if (message.isEmpty() || state != State.IDLE) return
+ val writer = input?.takeIf { process?.isAlive == true } ?: start(app) ?: return
+ record(app, Entry.Asked(message))
+ state = State.WORKING
+ AgentHostService.start(app)
+ Thread({
+ runCatching {
+ writer.write(userMessage(message))
+ writer.write("\n")
+ writer.flush()
+ }.onFailure { e ->
+ Log.w(TAG, "could not reach the agent", e)
+ main.post { finish(app, e.message ?: "agent stopped") }
+ }
+ }, "agent-send").start()
+ }
+
+ fun stop() {
+ process?.destroy()
+ process = null
+ input = null
+ }
+
+ fun forget(context: Context) {
+ stop()
+ entries.clear()
+ transcript(context).delete()
+ prefs(context).edit().remove(SESSION_KEY).apply()
+ state = if (installed) State.IDLE else State.MISSING
+ }
+
+ private fun start(app: Context): Writer? {
+ val workspace = if (File(LinuxRuntime.rootDir(app), WORKSPACE.removePrefix("/")).isDirectory) WORKSPACE else "/root"
+ val resume = prefs(app).getString(SESSION_KEY, null)
+ val claude = buildList {
+ addAll(listOf(CLAUDE, "-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"))
+ if (resume != null) addAll(listOf("--resume", resume))
+ }
+ val guest = listOf(
+ "/usr/bin/env", "HOME=/root", "PATH=/root/.local/bin:/usr/local/bin:/usr/bin:/bin",
+ "ADB_SERIAL=127.0.0.1:5555", "BROWSER=/root/.local/bin/android-open",
+ "/bin/sh", "-c", "cd \"$1\" && shift && exec \"$@\"", "sh", workspace,
+ ) + claude
+ val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), guest)
+ val builder = ProcessBuilder(command).redirectErrorStream(true)
+ builder.environment().apply {
+ remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH")
+ put("PROOT_LOADER", LinuxRuntime.prootLoader(app).path)
+ put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(app).path)
+ if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1")
+ LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
+ }
+ val child = try {
+ builder.start()
+ } catch (e: Exception) {
+ Log.w(TAG, "could not start the agent", e)
+ record(app, Entry.Finished(e.message ?: "could not start"))
+ return null
+ }
+ process = child
+ val writer = child.outputStream.bufferedWriter()
+ input = writer
+ Thread({
+ runCatching {
+ child.inputStream.bufferedReader().forEachLine { line ->
+ val event = parse(line) ?: return@forEachLine
+ main.post { apply(app, child, event) }
+ }
+ }
+ main.post {
+ if (process === child) {
+ process = null
+ input = null
+ if (state == State.WORKING) finish(app, "agent stopped")
+ }
+ }
+ }, "agent-output").start()
+ return writer
+ }
+
+ private fun apply(app: Context, child: Process, event: Event) {
+ if (process !== child) return
+ event.session?.let { prefs(app).edit().putString(SESSION_KEY, it).apply() }
+ event.entries.forEach { if (it is Entry.Finished) finish(app, it.error) else record(app, it) }
+ }
+
+ private fun finish(app: Context, error: String?) {
+ record(app, Entry.Finished(error))
+ state = State.IDLE
+ AgentHostService.stop(app)
+ }
+
+ private fun record(app: Context, entry: Entry) {
+ entries += entry
+ runCatching { transcript(app).apply { parentFile?.mkdirs() }.appendText(encode(entry) + "\n") }
+ }
+
+ private const val SESSION_KEY = "session_id"
+
+ private fun prefs(context: Context) = context.getSharedPreferences("agent", Context.MODE_PRIVATE)
+
+ private fun transcript(context: Context) = File(context.filesDir, "agent/transcript.jsonl")
+
+ @VisibleForTesting
+ internal data class Event(val session: String?, val entries: List)
+
+ @VisibleForTesting
+ internal fun userMessage(text: String): String = JSONObject()
+ .put("type", "user")
+ .put("message", JSONObject().put("role", "user").put("content", JSONArray().put(JSONObject().put("type", "text").put("text", text))))
+ .toString()
+
+ /** One stream-json line from Claude Code, or null when it carries nothing to show. */
+ @VisibleForTesting
+ internal fun parse(line: String): Event? {
+ val o = runCatching { JSONObject(line) }.getOrNull() ?: return null
+ val session = o.optString("session_id").takeIf { it.isNotEmpty() }
+ val entries = when (o.optString("type")) {
+ "assistant" -> {
+ val content = o.optJSONObject("message")?.optJSONArray("content") ?: JSONArray()
+ (0 until content.length()).mapNotNull { i ->
+ val block = content.optJSONObject(i) ?: return@mapNotNull null
+ when (block.optString("type")) {
+ "text" -> block.optString("text").trim().takeIf { it.isNotEmpty() }?.let { Entry.Said(it) }
+ "tool_use" -> Entry.Used(block.optString("name"), toolDetail(block.optJSONObject("input")))
+ else -> null
+ }
+ }
+ }
+ "result" -> listOf(Entry.Finished(
+ if (o.optBoolean("is_error")) o.optString("result").ifEmpty { o.optString("subtype") } else null,
+ ))
+ else -> emptyList()
+ }
+ return if (session == null && entries.isEmpty()) null else Event(session, entries)
+ }
+
+ private fun toolDetail(input: JSONObject?): String {
+ input ?: return ""
+ for (key in listOf("command", "file_path", "pattern", "url", "description")) {
+ input.optString(key).takeIf { it.isNotEmpty() }?.let { return it.lineSequence().first().take(160) }
+ }
+ return ""
+ }
+
+ @VisibleForTesting
+ internal fun encode(entry: Entry): String = when (entry) {
+ is Entry.Asked -> JSONObject().put("asked", entry.text)
+ is Entry.Said -> JSONObject().put("said", entry.text)
+ is Entry.Used -> JSONObject().put("used", entry.tool).put("detail", entry.detail)
+ is Entry.Finished -> JSONObject().put("finished", true).put("error", entry.error ?: JSONObject.NULL)
+ }.toString()
+
+ @VisibleForTesting
+ internal fun readTranscript(file: File): List = runCatching {
+ file.readLines().mapNotNull { line ->
+ val o = runCatching { JSONObject(line) }.getOrNull() ?: return@mapNotNull null
+ when {
+ o.has("asked") -> Entry.Asked(o.getString("asked"))
+ o.has("said") -> Entry.Said(o.getString("said"))
+ o.has("used") -> Entry.Used(o.getString("used"), o.optString("detail"))
+ o.has("finished") -> Entry.Finished(if (o.isNull("error")) null else o.optString("error"))
+ else -> null
+ }
+ }
+ }.getOrDefault(emptyList())
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentHostService.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentHostService.kt
new file mode 100644
index 000000000..4f172f652
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentHostService.kt
@@ -0,0 +1,51 @@
+package com.droiddeck.launcher.agent
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.IBinder
+import com.droiddeck.launcher.FrontEndActivity
+import com.droiddeck.launcher.R
+
+/** Keeps the app process alive while the agent works and the launcher is in the background. */
+class AgentHostService : Service() {
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val manager = getSystemService(NotificationManager::class.java)
+ manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.agent_channel_name),
+ NotificationManager.IMPORTANCE_LOW).apply { setShowBadge(false) })
+ val open = PendingIntent.getActivity(this, 0,
+ Intent(this, FrontEndActivity::class.java)
+ .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
+ .putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "agent"),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
+ val notification = Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_stat_session)
+ .setContentTitle(getString(R.string.agent_working))
+ .setContentIntent(open)
+ .setOngoing(true)
+ .setShowWhen(false)
+ .setCategory(Notification.CATEGORY_PROGRESS)
+ .build()
+ startForeground(NOTIFICATION_ID, notification)
+ return START_NOT_STICKY
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "agent"
+ private const val NOTIFICATION_ID = 4107
+
+ fun start(context: Context) {
+ runCatching { context.startForegroundService(Intent(context, AgentHostService::class.java)) }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, AgentHostService::class.java))
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt
new file mode 100644
index 000000000..8349c27fd
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/AgentPage.kt
@@ -0,0 +1,113 @@
+package com.droiddeck.launcher.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.Send
+import androidx.compose.material.icons.outlined.Add
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.agent.AgentHost
+import com.droiddeck.launcher.agent.AgentHost.Entry
+
+@Composable
+internal fun AgentPage(modifier: Modifier) {
+ val context = LocalContext.current
+ remember { AgentHost.refresh(context) }
+ if (AgentHost.state == AgentHost.State.MISSING) {
+ Column(modifier) { Note(stringResource(R.string.agent_missing)) }
+ return
+ }
+ val colors = MaterialTheme.colorScheme
+ val list = rememberLazyListState()
+ var draft by rememberSaveable { mutableStateOf("") }
+ val working = AgentHost.state == AgentHost.State.WORKING
+ val send = {
+ if (!working && draft.isNotBlank()) {
+ AgentHost.send(context, draft)
+ draft = ""
+ }
+ }
+ LaunchedEffect(AgentHost.entries.size) {
+ if (AgentHost.entries.isNotEmpty()) list.animateScrollToItem(AgentHost.entries.size - 1)
+ }
+ Column(modifier.fillMaxSize()) {
+ LazyColumn(Modifier.weight(1f).fillMaxWidth(), state = list, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ itemsIndexed(AgentHost.entries) { _, entry -> AgentEntry(entry) }
+ if (working) item {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp)) {
+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
+ }
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp)) {
+ IconButton(onClick = { AgentHost.forget(context) }, enabled = !working) {
+ Icon(Icons.Outlined.Add, stringResource(R.string.agent_new))
+ }
+ OutlinedTextField(
+ draft, { draft = it }, Modifier.weight(1f),
+ placeholder = { Text(stringResource(R.string.agent_hint)) },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
+ keyboardActions = KeyboardActions(onSend = { send() }),
+ maxLines = 4,
+ )
+ IconButton(onClick = send, enabled = !working && draft.isNotBlank()) {
+ Icon(Icons.AutoMirrored.Outlined.Send, stringResource(R.string.agent_send), tint = colors.primary)
+ }
+ }
+ }
+}
+
+@Composable
+private fun AgentEntry(entry: Entry) {
+ val colors = MaterialTheme.colorScheme
+ when (entry) {
+ is Entry.Asked -> Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
+ Text(entry.text, fontSize = 15.sp, color = colors.onPrimaryContainer,
+ modifier = Modifier.widthIn(max = 560.dp).clip(Shape12).background(colors.primaryContainer).padding(12.dp))
+ }
+ is Entry.Said -> Text(entry.text, fontSize = 15.sp, color = colors.onSurface, modifier = Modifier.widthIn(max = 720.dp).padding(horizontal = 4.dp))
+ is Entry.Used -> Text(
+ if (entry.detail.isEmpty()) entry.tool else "${entry.tool} ${entry.detail}",
+ fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant, maxLines = 2,
+ modifier = Modifier.padding(horizontal = 4.dp),
+ )
+ is Entry.Finished -> entry.error?.let {
+ Text(stringResource(R.string.agent_failed, it), fontSize = 13.sp, color = colors.error, modifier = Modifier.padding(horizontal = 4.dp))
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt
index 1c4536fc0..2259a2b34 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt
@@ -270,6 +270,10 @@ private fun Content(
DesktopPage(s, a, modifier, onSelect)
return
}
+ if (selected == "agent") {
+ AgentPage(modifier.padding(horizontal = padH).padding(top = padV))
+ return
+ }
if (selected == "android-apps" && s.isHomeApp) {
AndroidAppsPage(s, modifier, onAndroidAppClick)
return
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt
index ecc597c93..17e6b488b 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt
@@ -47,6 +47,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
+import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.LocalMall
@@ -123,7 +124,9 @@ internal fun BottomBar(
internal fun navigationKeys(s: FrontEndState) = buildList {
add("steam"); add("games")
if (s.gameStoresEnabled) add("stores")
- add("desktop"); add("components")
+ add("desktop")
+ if (com.droiddeck.launcher.agent.AgentHost.installed) add("agent")
+ add("components")
if (s.isHomeApp) add("android-apps")
add("setup"); add("updates")
}
@@ -156,7 +159,7 @@ internal fun SideRail(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
) {
- val count = 6 + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
+ val count = navigationKeys(s).size
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
val shown = Hops.railPreview ?: selected
@@ -197,6 +200,7 @@ internal fun railLabelIcon(key: String): Pair = when (key) {
"stores" -> R.string.rail_stores to Icons.Outlined.LocalMall
"desktop" -> R.string.rail_desktop to Icons.Outlined.DesktopWindows
"components" -> R.string.rail_components to Icons.Outlined.Layers
+ "agent" -> R.string.rail_agent to Icons.Outlined.AutoAwesome
"android-apps" -> R.string.rail_apps to Icons.Outlined.Apps
"setup" -> R.string.rail_setup to Icons.Outlined.Tune
else -> R.string.rail_updates to Icons.Outlined.SystemUpdate
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3bc07cd3f..519883471 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1141,6 +1141,14 @@
Lab build turned off
It was built for another version of DroidDeck. It is kept in Updates.
DroidDeck did not start on it twice
+ Agent
+ Agent
+ Agent is working
+ Install Claude Code in the Linux userland to use the agent.
+ Ask about a game, a crash, or a change to DroidDeck
+ Send
+ New conversation
+ Stopped: %1$s
PR #%1$d
Not built for this copy of DroidDeck
Updated %1$s
diff --git a/app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt b/app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt
new file mode 100644
index 000000000..032be581f
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/agent/AgentHostTest.kt
@@ -0,0 +1,57 @@
+package com.droiddeck.launcher.agent
+
+import com.droiddeck.launcher.agent.AgentHost.Entry
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.File
+import java.nio.file.Files
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [28], manifest = Config.NONE)
+class AgentHostTest {
+ @Test fun theInitLineCarriesOnlyTheConversation() {
+ val event = AgentHost.parse("""{"type":"system","subtype":"init","session_id":"abc","tools":[]}""")!!
+ assertEquals("abc", event.session)
+ assertEquals(emptyList(), event.entries)
+ }
+
+ @Test fun assistantTextAndToolUseBecomeEntries() {
+ val event = AgentHost.parse(
+ """{"type":"assistant","session_id":"abc","message":{"content":[
+ {"type":"text","text":" Looking at the log. "},
+ {"type":"tool_use","name":"Bash","input":{"command":"droiddeckctl state\nmore"}},
+ {"type":"thinking","thinking":"hidden"}]}}""",
+ )!!
+ assertEquals(listOf(Entry.Said("Looking at the log."), Entry.Used("Bash", "droiddeckctl state")), event.entries)
+ }
+
+ @Test fun aResultEndsTheTurn() {
+ assertEquals(listOf(Entry.Finished(null)), AgentHost.parse("""{"type":"result","subtype":"success","is_error":false,"result":"done"}""")!!.entries)
+ assertEquals(listOf(Entry.Finished("Invalid API key")),
+ AgentHost.parse("""{"type":"result","subtype":"success","is_error":true,"result":"Invalid API key"}""")!!.entries)
+ }
+
+ @Test fun linesWithNothingToShowAreSkipped() {
+ assertNull(AgentHost.parse("not json"))
+ assertNull(AgentHost.parse("""{"type":"user","message":{"content":[{"type":"tool_result","content":"ok"}]}}"""))
+ }
+
+ @Test fun aMessageIsSentAsOneStreamJsonUserLine() {
+ val o = JSONObject(AgentHost.userMessage("why did it crash?"))
+ assertEquals("user", o.getString("type"))
+ assertEquals("why did it crash?", o.getJSONObject("message").getJSONArray("content").getJSONObject(0).getString("text"))
+ }
+
+ @Test fun theTranscriptSurvivesARestart() {
+ val entries = listOf(Entry.Asked("hi"), Entry.Said("hello"), Entry.Used("Read", "/root/x"), Entry.Finished(null), Entry.Finished("stopped"))
+ val file = Files.createTempFile("transcript", ".jsonl").toFile()
+ file.writeText(entries.joinToString("\n") { AgentHost.encode(it) } + "\nbroken line\n")
+ assertEquals(entries, AgentHost.readTranscript(file))
+ assertEquals(emptyList(), AgentHost.readTranscript(File(file.path + ".missing")))
+ }
+}