Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.livekit.android.example.voiceassistant

import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import io.livekit.android.example.voiceassistant.screen.HermesApprovalDialog
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class HermesApprovalUiTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()

@Test
fun destructiveDialogHasOnlyPhysicalConfirmAndCancel() {
var choice: String? = null
composeRule.setContent {
HermesApprovalDialog(
request = ApprovalRequest(
runId = "run-1",
target = "fixture",
action = "Delete",
reason = "Requested for a no-op test",
agent = "Computer Operator"
),
onConfirm = { choice = "once" },
onCancel = { choice = "deny" }
)
}

composeRule.onNodeWithText("⚠ DESTRUCTIVE ACTION").assertExists()
composeRule.onNodeWithText("Agent: Computer Operator").assertExists()
composeRule.onNodeWithText("Action: Delete").assertExists()
composeRule.onNodeWithText("Target: fixture").assertExists()
composeRule.onNodeWithText("CANCEL").assertExists()
composeRule.onNodeWithText("CONFIRM").assertExists().performClick()
composeRule.onNodeWithText("APPROVE").assertDoesNotExist()
assertEquals("once", choice)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package io.livekit.android.example.voiceassistant

import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import io.livekit.android.example.voiceassistant.realtime.ControlPacket
import io.livekit.android.example.voiceassistant.screen.HermesChatTransport
import io.livekit.android.example.voiceassistant.screen.HermesScreen
import io.livekit.android.example.voiceassistant.screen.HermesSessionController
import kotlinx.coroutines.CompletableDeferred
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test

class HermesChatUiTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()

@Test
fun sendAddsBubbleBeforeTransportCompletes() {
val transport = FakeChatTransport(blockSend = true)
composeRule.setContent {
HermesScreen(FakeVoiceController(), chatTransport = transport)
}

submit("hello")

composeRule.onNodeWithText("hello").assertExists()
assertFalse(transport.completion.isCompleted)
assertEquals(1, transport.chatSends)
}

@Test
fun slashAndMentionSuggestionsAppearImmediately() {
composeRule.setContent {
HermesScreen(FakeVoiceController(), chatTransport = FakeChatTransport())
}

composeRule.onNodeWithTag("message_input").performTextInput("@c")
composeRule.onNodeWithText("@coder").assertExists()
composeRule.onNodeWithText("@computer-operator").assertExists()
composeRule.onNodeWithTag("message_input").performTextClearance()
composeRule.onNodeWithTag("message_input").performTextInput("/m")
composeRule.onNodeWithText("/mute").assertExists()
composeRule.onNodeWithText("/memory").assertExists()
}

@Test
fun muteAndEndCallDoNotSendChat() {
val transport = FakeChatTransport()
val voice = FakeVoiceController()
composeRule.setContent { HermesScreen(voice, chatTransport = transport) }

submit("/mute")
submit("/endcall")
composeRule.waitForIdle()

assertEquals(0, transport.chatSends)
assertEquals(listOf(false, false), voice.micStates)
}

private fun submit(text: String) {
composeRule.onNodeWithTag("message_input").performTextInput(text)
composeRule.onNodeWithTag("send_button").performClick()
composeRule.waitForIdle()
}

private class FakeChatTransport(
private val blockSend: Boolean = false
) : HermesChatTransport {
var chatSends = 0
val completion = CompletableDeferred<Unit>()

override suspend fun sendMessage(
text: String,
localId: String,
operationId: String
): String {
chatSends += 1
if (blockSend) completion.await()
return "stream-$chatSends"
}

override suspend fun sendControl(packet: ControlPacket): Boolean = true
}

private class FakeVoiceController : HermesSessionController {
val micStates = mutableListOf<Boolean>()
override val isConnected = true
override val isReconnecting = false

override suspend fun start(microphoneEnabled: Boolean) = Unit

override suspend fun setMicrophoneEnabled(enabled: Boolean) {
micStates += enabled
}

override suspend fun setAgentVolume(volume: Double) = Unit
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.livekit.android.example.voiceassistant

import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import io.livekit.android.example.voiceassistant.screen.HermesScreen
import io.livekit.android.example.voiceassistant.screen.HermesSessionController
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class HermesLifecycleTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()

@Test
fun launchShowsChatAndCallWithMicOff() {
val controller = FakeHermesSessionController()

composeRule.setContent { HermesScreen(controller) }
composeRule.waitForIdle()

composeRule.onNodeWithTag("conversation_timeline").assertExists()
composeRule.onNodeWithText("CALL HERMES").assertExists()
composeRule.onNodeWithText("মাইক্রোফোন বন্ধ").assertExists()
assertEquals(1, controller.startCalls)
assertEquals(false, controller.startMicrophoneEnabled)
assertEquals(0, controller.enableMicCalls)
}

private class FakeHermesSessionController : HermesSessionController {
var startCalls = 0
var startMicrophoneEnabled: Boolean? = null
var enableMicCalls = 0

override val isConnected = false
override val isReconnecting = false

override suspend fun start(microphoneEnabled: Boolean) {
startCalls += 1
startMicrophoneEnabled = microphoneEnabled
}

override suspend fun setMicrophoneEnabled(enabled: Boolean) {
if (enabled) enableMicCalls += 1
}

override suspend fun setAgentVolume(volume: Double) = Unit
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package io.livekit.android.example.voiceassistant

import com.google.gson.Gson

const val APPROVAL_REQUEST_TOPIC = "hermes.approval.request"
const val APPROVAL_RESPONSE_TOPIC = "hermes.approval.response"

data class ApprovalRequest(
val runId: String,
val target: String,
val action: String,
val reason: String,
val agent: String? = null
) {
val displayAgent: String
get() = agent?.takeIf(String::isNotBlank) ?: "Hermes Main"
}

private data class ApprovalResponse(val runId: String, val choice: String)

private val approvalGson = Gson()

fun parseApprovalRequest(data: ByteArray): ApprovalRequest? {
if (data.isEmpty() || data.size > 4096) return null
val request = runCatching {
approvalGson.fromJson(data.decodeToString(), ApprovalRequest::class.java)
}.getOrNull() ?: return null

return runCatching {
request.takeIf {
it.runId.isNotBlank() &&
it.target.isNotBlank() &&
it.action.isNotBlank() &&
it.reason.isNotBlank()
}
}.getOrNull()?.copy(
agent = request.agent
?.trim()
?.takeIf { it.length <= 80 && it.none(Char::isISOControl) }
)
}

fun approvalResponseJson(runId: String, choice: String): String? {
if (runId.isBlank() || choice !in setOf("once", "deny")) return null
return approvalGson.toJson(ApprovalResponse(runId, choice))
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import io.livekit.android.LiveKit
import io.livekit.android.example.voiceassistant.screen.ConnectRoute
import io.livekit.android.example.voiceassistant.screen.ConnectScreen
import io.livekit.android.example.voiceassistant.screen.VoiceAssistantRoute
import io.livekit.android.example.voiceassistant.screen.VoiceAssistantScreen
import io.livekit.android.example.voiceassistant.ui.theme.LiveKitVoiceAssistantExampleTheme
import io.livekit.android.example.voiceassistant.viewmodel.VoiceAssistantViewModel
Expand All @@ -24,34 +18,14 @@ class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
LiveKit.loggingLevel = LoggingLevel.DEBUG
LiveKit.loggingLevel = LoggingLevel.INFO

setContent {
val navController = rememberNavController()
LiveKitVoiceAssistantExampleTheme(dynamicColor = false) {
Scaffold { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {

// Set up NavHost for the app
NavHost(navController, startDestination = ConnectRoute) {
composable<ConnectRoute> {
ConnectScreen(navigateToVoiceAssistant = { voiceAssistantRoute ->
runOnUiThread {
navController.navigate(voiceAssistantRoute)
}
})
}

composable<VoiceAssistantRoute> {
val viewModel = viewModel<VoiceAssistantViewModel>()
VoiceAssistantScreen(
viewModel = viewModel,
onEndCall = {
runOnUiThread { navController.navigateUp() }
}
)
}
}
val viewModel = viewModel<VoiceAssistantViewModel>()
VoiceAssistantScreen(viewModel = viewModel)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package io.livekit.android.example.voiceassistant.realtime

enum class HermesCommand(val wireValue: String) {
NEW("new"),
STATUS("status"),
AGENTS("agents"),
TASKS("tasks"),
STOP("stop"),
VOICE("voice"),
CALL("call"),
ENDCALL("endcall"),
MUTE("mute"),
UNMUTE("unmute"),
MEMORY("memory"),
HELP("help")
}

sealed interface InputIntent {
data class Local(val command: HermesCommand) : InputIntent

data class Control(val command: HermesCommand) : InputIntent

data class Message(val text: String) : InputIntent
}

val SUPPORTED_MENTIONS = listOf(
"@main",
"@architect",
"@researcher",
"@coder",
"@browser",
"@computer-operator",
"@qa",
"@reviewer",
"@security",
"@ops"
)

val SUPPORTED_SLASH_COMMANDS = listOf(
"/new",
"/status",
"/agents",
"/tasks",
"/stop",
"/voice",
"/call",
"/endcall",
"/mute",
"/unmute",
"/memory",
"/help"
)

private val LOCAL_COMMANDS = mapOf(
"/mute" to HermesCommand.MUTE,
"/unmute" to HermesCommand.UNMUTE,
"/voice" to HermesCommand.VOICE,
"/call" to HermesCommand.CALL,
"/endcall" to HermesCommand.ENDCALL,
"/help" to HermesCommand.HELP
)

private val CONTROL_COMMANDS = mapOf(
"/new" to HermesCommand.NEW,
"/stop" to HermesCommand.STOP,
"/status" to HermesCommand.STATUS
)

fun parseInput(text: String): InputIntent {
val normalized = text.trim().lowercase()
LOCAL_COMMANDS[normalized]?.let { return InputIntent.Local(it) }
CONTROL_COMMANDS[normalized]?.let { return InputIntent.Control(it) }
return InputIntent.Message(text)
}

fun suggestInputs(text: String): List<String> {
val prefix = text.trimStart().lowercase()
if (prefix.isEmpty() || prefix.any(Char::isWhitespace)) return emptyList()
return when {
prefix.startsWith("@") -> SUPPORTED_MENTIONS.filter { it.startsWith(prefix) }
prefix.startsWith("/") -> SUPPORTED_SLASH_COMMANDS.filter {
it.startsWith(prefix)
}
else -> emptyList()
}
}
Loading