Initialize Meatbag SDUI platform
This commit is contained in:
29
backend/build.gradle.kts
Normal file
29
backend/build.gradle.kts
Normal file
@@ -0,0 +1,29 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
id("io.ktor.plugin")
|
||||
}
|
||||
|
||||
group = "meatbag"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
application {
|
||||
mainClass.set("meatbag.backend.ApplicationKt")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":common"))
|
||||
implementation("io.ktor:ktor-server-core-jvm:2.3.11")
|
||||
implementation("io.ktor:ktor-server-netty-jvm:2.3.11")
|
||||
implementation("io.ktor:ktor-server-websockets-jvm:2.3.11")
|
||||
implementation("io.ktor:ktor-server-content-negotiation-jvm:2.3.11")
|
||||
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:2.3.11")
|
||||
implementation("io.ktor:ktor-server-cors-jvm:2.3.11")
|
||||
implementation("io.ktor:ktor-server-call-logging-jvm:2.3.11")
|
||||
implementation("ch.qos.logback:logback-classic:1.4.14")
|
||||
|
||||
// Test dependencies including Ktor client plugins for testApplication
|
||||
testImplementation("io.ktor:ktor-server-tests-jvm:2.3.11")
|
||||
testImplementation("io.ktor:ktor-client-content-negotiation-jvm:2.3.11")
|
||||
testImplementation("io.ktor:ktor-client-websockets-jvm:2.3.11")
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.9.23")
|
||||
}
|
||||
145
backend/src/main/kotlin/meatbag/backend/Application.kt
Normal file
145
backend/src/main/kotlin/meatbag/backend/Application.kt
Normal file
@@ -0,0 +1,145 @@
|
||||
package meatbag.backend
|
||||
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.netty.*
|
||||
import io.ktor.server.plugins.callloging.*
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
import io.ktor.server.plugins.cors.routing.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.websocket.*
|
||||
import io.ktor.websocket.*
|
||||
import meatbag.common.*
|
||||
import java.time.Duration
|
||||
|
||||
fun main() {
|
||||
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module).start(wait = true)
|
||||
}
|
||||
|
||||
fun Application.module() {
|
||||
install(CallLogging)
|
||||
|
||||
install(CORS) {
|
||||
anyHost()
|
||||
allowHeader(HttpHeaders.ContentType)
|
||||
allowHeader(HttpHeaders.Authorization)
|
||||
allowMethod(HttpMethod.Options)
|
||||
allowMethod(HttpMethod.Post)
|
||||
allowMethod(HttpMethod.Get)
|
||||
}
|
||||
|
||||
install(WebSockets) {
|
||||
pingPeriod = Duration.ofSeconds(15)
|
||||
timeout = Duration.ofSeconds(15)
|
||||
maxFrameSize = Long.MAX_VALUE
|
||||
masking = false
|
||||
}
|
||||
|
||||
install(ContentNegotiation) {
|
||||
json(SDUIJson.json)
|
||||
}
|
||||
|
||||
val deviceRegistry = DeviceRegistry()
|
||||
val routingEngine = RoutingEngine(deviceRegistry)
|
||||
|
||||
routing {
|
||||
// Client WebSocket connection endpoint
|
||||
webSocket("/ws/connect") {
|
||||
val deviceId = call.parameters["deviceId"] ?: java.util.UUID.randomUUID().toString()
|
||||
val deviceTypeStr = call.parameters["deviceType"] ?: "DESKTOP"
|
||||
val deviceType = try {
|
||||
DeviceType.valueOf(deviceTypeStr.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
DeviceType.DESKTOP
|
||||
}
|
||||
|
||||
val device = ActiveDevice(deviceId, deviceType, this)
|
||||
deviceRegistry.register(device)
|
||||
println("[WebSocket] Device registered: ID=$deviceId, Type=$deviceType")
|
||||
|
||||
try {
|
||||
for (frame in incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val text = frame.readText()
|
||||
println("[WebSocket] Message from device $deviceId: $text")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("[WebSocket] Connection error or close for device $deviceId: ${e.localizedMessage}")
|
||||
} finally {
|
||||
deviceRegistry.unregister(deviceId)
|
||||
println("[WebSocket] Device unregistered: ID=$deviceId")
|
||||
}
|
||||
}
|
||||
|
||||
// Endpoint for receiving responses from clients
|
||||
post("/api/respond") {
|
||||
val response = try {
|
||||
call.receive<ApprovalResponse>()
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Invalid Response body structure: ${e.localizedMessage}")
|
||||
return@post
|
||||
}
|
||||
|
||||
val matchedPendingRequest = routingEngine.handleResponse(response)
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
mapOf(
|
||||
"status" to "Response processed",
|
||||
"matchedPendingRequest" to matchedPendingRequest
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Endpoint for receiving requests from agents
|
||||
post("/api/request") {
|
||||
val rawJson = call.receiveText()
|
||||
val uiPrimitive = try {
|
||||
SDUIJson.decodeFromString(rawJson)
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Invalid UI Primitive format: ${e.localizedMessage}")
|
||||
return@post
|
||||
}
|
||||
|
||||
try {
|
||||
val approvalResult = routingEngine.routeRequest(
|
||||
AgentRequestContext(
|
||||
uiPrimitive = uiPrimitive,
|
||||
urgent = call.request.queryParameters["urgent"]?.toBoolean() ?: false,
|
||||
requestedAttention = parseAttentionTier(call.request.queryParameters["attentionTier"]),
|
||||
preferredDeviceTypes = parseDeviceTypes(call.request.queryParameters["deviceTypes"])
|
||||
)
|
||||
)
|
||||
call.respond(HttpStatusCode.OK, approvalResult)
|
||||
} catch (e: IllegalStateException) {
|
||||
call.respond(HttpStatusCode.ServiceUnavailable, mapOf("error" to e.localizedMessage))
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to e.localizedMessage))
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic / status endpoint
|
||||
get("/api/status") {
|
||||
val devices = deviceRegistry.getAllDevices().map { mapOf("id" to it.id, "type" to it.type.name) }
|
||||
call.respond(mapOf("status" to "OK", "connectedDevices" to devices))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAttentionTier(value: String?): AttentionTier {
|
||||
return value
|
||||
?.let { runCatching { AttentionTier.valueOf(it.uppercase()) }.getOrNull() }
|
||||
?: AttentionTier.BACKGROUND_NOTIFICATION
|
||||
}
|
||||
|
||||
private fun parseDeviceTypes(value: String?): List<DeviceType> {
|
||||
return value
|
||||
?.split(",")
|
||||
?.mapNotNull { rawType -> runCatching { DeviceType.valueOf(rawType.trim().uppercase()) }.getOrNull() }
|
||||
?.distinct()
|
||||
?: emptyList()
|
||||
}
|
||||
31
backend/src/main/kotlin/meatbag/backend/DeviceRegistry.kt
Normal file
31
backend/src/main/kotlin/meatbag/backend/DeviceRegistry.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package meatbag.backend
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class DeviceRegistry {
|
||||
private val devices = ConcurrentHashMap<String, ActiveDevice>()
|
||||
|
||||
fun register(device: ActiveDevice) {
|
||||
devices[device.id] = device
|
||||
}
|
||||
|
||||
fun unregister(deviceId: String) {
|
||||
devices.remove(deviceId)
|
||||
}
|
||||
|
||||
fun getDevice(deviceId: String): ActiveDevice? {
|
||||
return devices[deviceId]
|
||||
}
|
||||
|
||||
fun getAllDevices(): List<ActiveDevice> {
|
||||
return devices.values.toList()
|
||||
}
|
||||
|
||||
fun getDevicesByType(deviceTypes: Set<DeviceType>): List<ActiveDevice> {
|
||||
if (deviceTypes.isEmpty()) return emptyList()
|
||||
|
||||
return devices.values
|
||||
.filter { it.type in deviceTypes }
|
||||
.sortedWith(compareBy<ActiveDevice> { it.type.ordinal }.thenBy { it.id })
|
||||
}
|
||||
}
|
||||
53
backend/src/main/kotlin/meatbag/backend/Models.kt
Normal file
53
backend/src/main/kotlin/meatbag/backend/Models.kt
Normal file
@@ -0,0 +1,53 @@
|
||||
package meatbag.backend
|
||||
|
||||
import io.ktor.websocket.*
|
||||
import meatbag.common.AttentionTier
|
||||
import meatbag.common.UiPrimitive
|
||||
|
||||
enum class DeviceType {
|
||||
WEAR_OS_WATCH,
|
||||
PHONE,
|
||||
DESKTOP
|
||||
}
|
||||
|
||||
data class ActiveDevice(
|
||||
val id: String,
|
||||
val type: DeviceType,
|
||||
val session: WebSocketSession
|
||||
)
|
||||
|
||||
data class AgentRequestContext(
|
||||
val uiPrimitive: UiPrimitive,
|
||||
val requestedAttention: AttentionTier = AttentionTier.BACKGROUND_NOTIFICATION,
|
||||
val urgent: Boolean = false,
|
||||
val preferredDeviceTypes: List<DeviceType> = emptyList()
|
||||
)
|
||||
|
||||
data class UserRuleSet(
|
||||
val id: String = "mock-default",
|
||||
val criticalKeywords: Set<String> = setOf(
|
||||
"rm -rf",
|
||||
"delete",
|
||||
"destroy",
|
||||
"kill",
|
||||
"urgent",
|
||||
"critical",
|
||||
"sudo",
|
||||
"permission",
|
||||
"allow"
|
||||
),
|
||||
val watchFirstForSilentHaptic: Boolean = true,
|
||||
val fanOutOverrideScreen: Boolean = true
|
||||
)
|
||||
|
||||
data class RoutingDecision(
|
||||
val attentionTier: AttentionTier,
|
||||
val deviceTypes: List<DeviceType>,
|
||||
val fanOut: Boolean = false
|
||||
)
|
||||
|
||||
data class DeliveryResult(
|
||||
val requestId: String,
|
||||
val attentionTier: AttentionTier,
|
||||
val deviceIds: List<String>
|
||||
)
|
||||
235
backend/src/main/kotlin/meatbag/backend/RoutingEngine.kt
Normal file
235
backend/src/main/kotlin/meatbag/backend/RoutingEngine.kt
Normal file
@@ -0,0 +1,235 @@
|
||||
package meatbag.backend
|
||||
|
||||
import io.ktor.websocket.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.serialization.encodeToString
|
||||
import meatbag.common.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
interface AgentCallback {
|
||||
suspend fun onHumanResponse(response: ApprovalResponse)
|
||||
}
|
||||
|
||||
class RecordingAgentCallback : AgentCallback {
|
||||
private val responses = ConcurrentHashMap<String, ApprovalResponse>()
|
||||
|
||||
override suspend fun onHumanResponse(response: ApprovalResponse) {
|
||||
responses[response.requestId] = response
|
||||
}
|
||||
|
||||
fun getResponse(requestId: String): ApprovalResponse? = responses[requestId]
|
||||
}
|
||||
|
||||
class RoutingEngine(
|
||||
private val deviceRegistry: DeviceRegistry,
|
||||
private val userRuleSet: UserRuleSet = UserRuleSet(),
|
||||
private val agentCallback: AgentCallback = RecordingAgentCallback()
|
||||
) {
|
||||
// Tracks pending requests: requestId -> CompletableDeferred<ApprovalResponse>
|
||||
private val pendingRequests = ConcurrentHashMap<String, CompletableDeferred<ApprovalResponse>>()
|
||||
|
||||
suspend fun routeRequest(
|
||||
uiPrimitive: UiPrimitive,
|
||||
isUrgent: Boolean,
|
||||
requestedAttention: AttentionTier
|
||||
): ApprovalResponse {
|
||||
return routeRequest(
|
||||
AgentRequestContext(
|
||||
uiPrimitive = uiPrimitive,
|
||||
requestedAttention = requestedAttention,
|
||||
urgent = isUrgent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun routeRequest(context: AgentRequestContext): ApprovalResponse {
|
||||
val activeDevices = deviceRegistry.getAllDevices()
|
||||
if (activeDevices.isEmpty()) {
|
||||
throw IllegalStateException("No active client devices connected")
|
||||
}
|
||||
|
||||
val decision = evaluateRules(context)
|
||||
val targetDevices = selectDevices(decision, activeDevices)
|
||||
|
||||
val deferred = CompletableDeferred<ApprovalResponse>()
|
||||
pendingRequests[context.uiPrimitive.id] = deferred
|
||||
|
||||
println(
|
||||
"Routing request ${context.uiPrimitive.id} to devices " +
|
||||
targetDevices.joinToString { "${it.id} (${it.type})" } +
|
||||
" with tier ${decision.attentionTier}"
|
||||
)
|
||||
|
||||
try {
|
||||
targetDevices.forEach { device ->
|
||||
device.session.send(Frame.Text(payloadJson(context, decision, device)))
|
||||
}
|
||||
|
||||
return deferred.await()
|
||||
} catch (e: Exception) {
|
||||
pendingRequests.remove(context.uiPrimitive.id)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deliverRequest(context: AgentRequestContext): DeliveryResult {
|
||||
val activeDevices = deviceRegistry.getAllDevices()
|
||||
if (activeDevices.isEmpty()) {
|
||||
throw IllegalStateException("No active client devices connected")
|
||||
}
|
||||
|
||||
val decision = evaluateRules(context)
|
||||
val targetDevices = selectDevices(decision, activeDevices)
|
||||
targetDevices.forEach { device ->
|
||||
device.session.send(Frame.Text(payloadJson(context, decision, device)))
|
||||
}
|
||||
|
||||
return DeliveryResult(
|
||||
requestId = context.uiPrimitive.id,
|
||||
attentionTier = decision.attentionTier,
|
||||
deviceIds = targetDevices.map { it.id }
|
||||
)
|
||||
}
|
||||
|
||||
fun evaluateRules(context: AgentRequestContext): RoutingDecision {
|
||||
if (context.preferredDeviceTypes.isNotEmpty()) {
|
||||
return RoutingDecision(
|
||||
attentionTier = context.requestedAttention,
|
||||
deviceTypes = context.preferredDeviceTypes,
|
||||
fanOut = context.preferredDeviceTypes.size > 1
|
||||
)
|
||||
}
|
||||
|
||||
val promptRequiresAttention = (context.uiPrimitive as? ApprovalRequest)
|
||||
?.prompt
|
||||
?.lowercase()
|
||||
?.let { prompt -> userRuleSet.criticalKeywords.any(prompt::contains) }
|
||||
?: false
|
||||
|
||||
val attentionTier = when {
|
||||
context.requestedAttention == AttentionTier.OVERRIDE_SCREEN -> AttentionTier.OVERRIDE_SCREEN
|
||||
context.urgent || promptRequiresAttention -> AttentionTier.SILENT_HAPTIC
|
||||
else -> context.requestedAttention
|
||||
}
|
||||
|
||||
val deviceTypes = when (attentionTier) {
|
||||
AttentionTier.SILENT_HAPTIC -> {
|
||||
if (userRuleSet.watchFirstForSilentHaptic) {
|
||||
listOf(DeviceType.WEAR_OS_WATCH, DeviceType.PHONE, DeviceType.DESKTOP)
|
||||
} else {
|
||||
listOf(DeviceType.PHONE, DeviceType.WEAR_OS_WATCH, DeviceType.DESKTOP)
|
||||
}
|
||||
}
|
||||
AttentionTier.OVERRIDE_SCREEN -> {
|
||||
if (userRuleSet.fanOutOverrideScreen) {
|
||||
listOf(DeviceType.PHONE, DeviceType.DESKTOP, DeviceType.WEAR_OS_WATCH)
|
||||
} else {
|
||||
listOf(DeviceType.PHONE, DeviceType.DESKTOP)
|
||||
}
|
||||
}
|
||||
AttentionTier.BACKGROUND_NOTIFICATION -> listOf(DeviceType.PHONE, DeviceType.DESKTOP, DeviceType.WEAR_OS_WATCH)
|
||||
}
|
||||
|
||||
return RoutingDecision(
|
||||
attentionTier = attentionTier,
|
||||
deviceTypes = deviceTypes,
|
||||
fanOut = attentionTier == AttentionTier.OVERRIDE_SCREEN && userRuleSet.fanOutOverrideScreen
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun handleResponse(response: ApprovalResponse): Boolean {
|
||||
val deferred = pendingRequests.remove(response.requestId)
|
||||
if (deferred != null) {
|
||||
agentCallback.onHumanResponse(response)
|
||||
deferred.complete(response)
|
||||
println("Completed request ${response.requestId} with approved=${response.approved}")
|
||||
return true
|
||||
} else {
|
||||
println("No pending request found for ID ${response.requestId}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectDevices(decision: RoutingDecision, activeDevices: List<ActiveDevice>): List<ActiveDevice> {
|
||||
val orderedMatches = decision.deviceTypes.flatMap { desiredType ->
|
||||
activeDevices.filter { it.type == desiredType }.sortedBy { it.id }
|
||||
}
|
||||
|
||||
return when {
|
||||
orderedMatches.isNotEmpty() && decision.fanOut -> orderedMatches
|
||||
orderedMatches.isNotEmpty() -> listOf(orderedMatches.first())
|
||||
else -> listOf(activeDevices.sortedWith(compareBy<ActiveDevice> { it.type.ordinal }.thenBy { it.id }).first())
|
||||
}
|
||||
}
|
||||
|
||||
private fun payloadJson(
|
||||
context: AgentRequestContext,
|
||||
decision: RoutingDecision,
|
||||
device: ActiveDevice
|
||||
): String {
|
||||
return SDUIJson.json.encodeToString(
|
||||
WebSocketPayload(
|
||||
attentionTier = decision.attentionTier,
|
||||
uiPrimitive = context.uiPrimitive,
|
||||
request = RequestContext(
|
||||
requestId = context.uiPrimitive.id,
|
||||
source = RequestSource(
|
||||
system = "agent",
|
||||
reason = if (context.urgent) "urgent" else null
|
||||
)
|
||||
),
|
||||
deviceContext = device.toDeviceContext(),
|
||||
attentionPolicy = AttentionPolicy(
|
||||
tier = decision.attentionTier,
|
||||
hapticPattern = decision.attentionTier.toHapticPattern(),
|
||||
presentation = decision.toPresentationMode()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ActiveDevice.toDeviceContext(): DeviceContext {
|
||||
return when (type) {
|
||||
DeviceType.WEAR_OS_WATCH -> DeviceContext(
|
||||
deviceId = id,
|
||||
deviceClass = DeviceClass.WEARABLE,
|
||||
platform = DevicePlatform.WEAR_OS,
|
||||
capabilities = setOf(DeviceCapability.HAPTICS, DeviceCapability.NOTIFICATIONS)
|
||||
)
|
||||
DeviceType.PHONE -> DeviceContext(
|
||||
deviceId = id,
|
||||
deviceClass = DeviceClass.PHONE,
|
||||
platform = DevicePlatform.ANDROID,
|
||||
capabilities = setOf(
|
||||
DeviceCapability.HAPTICS,
|
||||
DeviceCapability.NOTIFICATIONS,
|
||||
DeviceCapability.FULL_SCREEN_INTENT,
|
||||
DeviceCapability.TEXT_INPUT
|
||||
)
|
||||
)
|
||||
DeviceType.DESKTOP -> DeviceContext(
|
||||
deviceId = id,
|
||||
deviceClass = DeviceClass.DESKTOP,
|
||||
platform = DevicePlatform.DESKTOP,
|
||||
capabilities = setOf(DeviceCapability.NOTIFICATIONS, DeviceCapability.TEXT_INPUT)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AttentionTier.toHapticPattern(): HapticPattern {
|
||||
return when (this) {
|
||||
AttentionTier.SILENT_HAPTIC -> HapticPattern.DOUBLE_SHORT
|
||||
AttentionTier.BACKGROUND_NOTIFICATION -> HapticPattern.SHORT
|
||||
AttentionTier.OVERRIDE_SCREEN -> HapticPattern.URGENT_LOOP
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoutingDecision.toPresentationMode(): PresentationMode {
|
||||
return when {
|
||||
fanOut -> PresentationMode.ALL_DEVICES
|
||||
deviceTypes.firstOrNull() == DeviceType.WEAR_OS_WATCH -> PresentationMode.WATCH_ONLY
|
||||
deviceTypes.firstOrNull() == DeviceType.PHONE -> PresentationMode.PHONE_ONLY
|
||||
else -> PresentationMode.AUTO
|
||||
}
|
||||
}
|
||||
}
|
||||
14
backend/src/main/resources/logback.xml
Normal file
14
backend/src/main/resources/logback.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</root>
|
||||
|
||||
<logger name="org.eclipse.jetty" level="INFO"/>
|
||||
<logger name="io.netty" level="INFO"/>
|
||||
</configuration>
|
||||
170
backend/src/test/kotlin/meatbag/backend/RoutingEngineTest.kt
Normal file
170
backend/src/test/kotlin/meatbag/backend/RoutingEngineTest.kt
Normal file
@@ -0,0 +1,170 @@
|
||||
package meatbag.backend
|
||||
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.websocket.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.testing.*
|
||||
import io.ktor.websocket.*
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import meatbag.common.*
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RoutingEngineTest {
|
||||
|
||||
@Test
|
||||
fun testCompleteAgentRoutingFlow() = testApplication {
|
||||
// Configure Ktor backend module
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Client for WebSocket connection
|
||||
val wsClient = createClient {
|
||||
install(WebSockets)
|
||||
install(ContentNegotiation) {
|
||||
json(SDUIJson.json)
|
||||
}
|
||||
}
|
||||
|
||||
// Client for HTTP API calls
|
||||
val httpClient = createClient {
|
||||
install(ContentNegotiation) {
|
||||
json(SDUIJson.json)
|
||||
}
|
||||
}
|
||||
|
||||
// Run the client WebSocket session and agent HTTP request concurrently
|
||||
wsClient.webSocket("/ws/connect?deviceType=WEAR_OS_WATCH&deviceId=watch_123") {
|
||||
val agentResponseDeferred = async {
|
||||
val approvalReq = ApprovalRequest(
|
||||
id = "req_999",
|
||||
prompt = "Urgent: Allow system update?",
|
||||
target = "system-agent"
|
||||
)
|
||||
// Post approval request to backend polymorphically
|
||||
httpClient.post("/api/request") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody<UiPrimitive>(approvalReq)
|
||||
}
|
||||
}
|
||||
|
||||
// Client receives the websocket notification frame
|
||||
val frame = incoming.receive()
|
||||
assertTrue(frame is Frame.Text, "Expected text frame")
|
||||
val textFrame = frame as Frame.Text
|
||||
val payloadJson = textFrame.readText()
|
||||
|
||||
// Decode payload using common module's serializer
|
||||
val payload = SDUIJson.json.decodeFromString<WebSocketPayload>(payloadJson)
|
||||
// Due to "Urgent" in prompt, it should resolve to SILENT_HAPTIC and target watch_123
|
||||
assertEquals(AttentionTier.SILENT_HAPTIC, payload.attentionTier)
|
||||
assertEquals("req_999", payload.uiPrimitive.id)
|
||||
assertTrue(payload.uiPrimitive is ApprovalRequest)
|
||||
assertEquals("Urgent: Allow system update?", (payload.uiPrimitive as ApprovalRequest).prompt)
|
||||
|
||||
// Simulate user clicking Yes on their watch by sending response to /api/respond
|
||||
val respondResponse = httpClient.post("/api/respond") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ApprovalResponse(requestId = "req_999", approved = true))
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, respondResponse.status)
|
||||
|
||||
// Await agent response and verify the callback output
|
||||
val agentResponse = agentResponseDeferred.await()
|
||||
assertEquals(HttpStatusCode.OK, agentResponse.status)
|
||||
val result = agentResponse.body<ApprovalResponse>()
|
||||
assertEquals("req_999", result.requestId)
|
||||
assertEquals(true, result.approved)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOverrideScreenFansOutToPhoneAndDesktop() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
val wsClient = createClient {
|
||||
install(WebSockets)
|
||||
install(ContentNegotiation) {
|
||||
json(SDUIJson.json)
|
||||
}
|
||||
}
|
||||
|
||||
val httpClient = createClient {
|
||||
install(ContentNegotiation) {
|
||||
json(SDUIJson.json)
|
||||
}
|
||||
}
|
||||
|
||||
val phonePayloadDeferred = async {
|
||||
wsClient.webSocket("/ws/connect?deviceType=PHONE&deviceId=phone_123") {
|
||||
val frame = incoming.receive()
|
||||
assertTrue(frame is Frame.Text, "Expected text frame")
|
||||
SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
|
||||
}
|
||||
}
|
||||
|
||||
val desktopPayloadDeferred = async {
|
||||
wsClient.webSocket("/ws/connect?deviceType=DESKTOP&deviceId=desktop_123") {
|
||||
val frame = incoming.receive()
|
||||
assertTrue(frame is Frame.Text, "Expected text frame")
|
||||
SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
|
||||
}
|
||||
}
|
||||
|
||||
var connected = false
|
||||
var attempts = 0
|
||||
while (!connected && attempts < 20) {
|
||||
val response = httpClient.get("/api/status")
|
||||
val body = response.bodyAsText()
|
||||
if (body.contains("phone_123") && body.contains("desktop_123")) {
|
||||
connected = true
|
||||
break
|
||||
}
|
||||
attempts += 1
|
||||
delay(25)
|
||||
}
|
||||
assertTrue(connected, "Expected both devices to connect before routing")
|
||||
|
||||
val agentResponseDeferred = async {
|
||||
httpClient.post("/api/request?attentionTier=OVERRIDE_SCREEN") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody<UiPrimitive>(
|
||||
ApprovalRequest(
|
||||
id = "req_override",
|
||||
prompt = "Approve remote screen takeover?",
|
||||
target = "system-agent"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val phonePayload = phonePayloadDeferred.await()
|
||||
val desktopPayload = desktopPayloadDeferred.await()
|
||||
|
||||
assertEquals(AttentionTier.OVERRIDE_SCREEN, phonePayload.attentionTier)
|
||||
assertEquals(AttentionTier.OVERRIDE_SCREEN, desktopPayload.attentionTier)
|
||||
assertEquals("req_override", phonePayload.uiPrimitive.id)
|
||||
assertEquals("req_override", desktopPayload.uiPrimitive.id)
|
||||
|
||||
val respondResponse = httpClient.post("/api/respond") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ApprovalResponse(requestId = "req_override", approved = false))
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, respondResponse.status)
|
||||
|
||||
val agentResponse = agentResponseDeferred.await()
|
||||
assertEquals(HttpStatusCode.OK, agentResponse.status)
|
||||
val result = agentResponse.body<ApprovalResponse>()
|
||||
assertEquals("req_override", result.requestId)
|
||||
assertEquals(false, result.approved)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user