mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
feat(agent): an Agent tab in the launcher that talks to Claude Code outside any session
This commit is contained in:
1 parent
2e34213b28
commit
71481ac2ff
8 files changed
+470
-2
No files matched your search
@@ -302,6 +302,7 @@
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density"
|
||||
android:theme="@style/SessionTheme" />
|
||||
|
||||
<service android:name=".agent.AgentHostService" android:exported="false" />
|
||||
<service
|
||||
android:name=".session.SessionService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.Writer
|
||||
import java.nio.file.Files
|
||||
|
||||
/**
|
||||
* The launcher's agent: Claude Code from the Linux userland, run headless with stream-json on
|
||||
* stdin and stdout, outside any game or desktop session. The conversation id and transcript are
|
||||
* kept on disk, so a restart (a Lab build, a crash) continues the same conversation.
|
||||
*/
|
||||
object AgentHost {
|
||||
sealed interface Entry {
|
||||
data class Asked(val text: String) : Entry
|
||||
data class Said(val text: String) : Entry
|
||||
data class Used(val tool: String, val detail: String) : Entry
|
||||
data class Finished(val error: String?) : Entry
|
||||
}
|
||||
|
||||
enum class State { MISSING, IDLE, WORKING }
|
||||
|
||||
private const val TAG = "AgentHost"
|
||||
private const val CLAUDE = "/root/.local/bin/claude"
|
||||
private const val WORKSPACE = "/root/DroidDeck"
|
||||
|
||||
val entries = mutableStateListOf<Entry>()
|
||||
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<Entry>)
|
||||
|
||||
@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<Entry> = 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())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<Int, ImageVector> = 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
|
||||
|
||||
@@ -1141,6 +1141,14 @@
|
||||
<string name="lab_off_title">Lab build turned off</string>
|
||||
<string name="lab_off_other_install">It was built for another version of DroidDeck. It is kept in Updates.</string>
|
||||
<string name="lab_off_did_not_start">DroidDeck did not start on it twice</string>
|
||||
<string name="rail_agent">Agent</string>
|
||||
<string name="agent_channel_name">Agent</string>
|
||||
<string name="agent_working">Agent is working</string>
|
||||
<string name="agent_missing">Install Claude Code in the Linux userland to use the agent.</string>
|
||||
<string name="agent_hint">Ask about a game, a crash, or a change to DroidDeck</string>
|
||||
<string name="agent_send">Send</string>
|
||||
<string name="agent_new">New conversation</string>
|
||||
<string name="agent_failed">Stopped: %1$s</string>
|
||||
<string name="upd_pr">PR #%1$d</string>
|
||||
<string name="upd_not_built">Not built for this copy of DroidDeck</string>
|
||||
<string name="upd_updated">Updated %1$s</string>
|
||||
|
||||
@@ -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<Entry>(), 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<Entry>(), AgentHost.readTranscript(File(file.path + ".missing")))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user