Ассистент Порфирий: тула времени, сессии, чат-UI, голосовой цикл; BT-транспорт очки↔телефон

- BT (RFCOMM SPP 00001105) вместо WiFi: BtGlassesTransport/BtServerTransport, реконнект 3с
- PlaybackService (foreground, WAKE_LOCK) против морозов HONOR
- Ассистент: AgentTools (TimeTool ISO 8601), TOOL_CALL-протокол, сессии SQLite (UUID)
- Чат-UI: создание/сброс/переключение сессий, автообновление ленты 2с
- LlmClient на прямом OkHttp (Koog давал пустой content на длинных историях)
- Голосовой цикл: тройной тап на очках → STT → активная сессия → ShowText
- Тесты: AgentToolsTest (3), GlassesServerTest переписан (6) — зелёные
This commit is contained in:
2026-08-22 16:31:04 +03:00
parent adfe376dd4
commit bbf7a020d4
28 changed files with 1802 additions and 342 deletions
@@ -2,7 +2,6 @@ package pw.binom.viewmate.phone
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.server.testing.testApplication
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import io.ktor.websocket.send
@@ -30,17 +29,34 @@ import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.fail
/**
* Интеграционные тесты хаба поверх реального [WifiServerTransport] (Ktor CIO):
* поднимаем сервер на свободном порту, ходим WS-клиентом, проверяем
* handshake/жесты/бродкаст/статусы (TASK-transport.md п.5: «тесты зелёные»).
*/
class GlassesServerTest {
private val json = protocolJson
@Test
fun helloGetsWelcome() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
/** Поднять [GlassesServer] на случайном порту и вернуть его + порт. */
private fun startServer(): Pair<GlassesServer, Int> {
val port = freePort()
val server = GlassesServer(port = port, state = PhoneState(), useBluetooth = false)
server.start()
return server to port
}
client.webSocket(GLASSES_WS_PATH) {
private fun freePort(): Int {
val s = java.net.ServerSocket(0)
val p = s.localPort
s.close()
return p
}
@Test
fun helloGetsWelcome() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
val frame = incoming.receive() as Frame.Text
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
@@ -50,12 +66,9 @@ class GlassesServerTest {
}
@Test
fun gestureIsProcessed() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
fun gestureIsProcessed() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
}
@@ -64,28 +77,22 @@ class GlassesServerTest {
}
@Test
fun connectedCounterTracksSessions() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) { }
fun connectedCounterTracksSessions() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { }
awaitTrue { hub.connected.value == 0 }
}
@Test
fun broadcastReachesAllGlasses() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
fun broadcastReachesAllGlasses() = runServer { port, hub ->
val client = wsClient()
coroutineScope {
val opened = Channel<Unit>(2)
val received = Channel<HostToGlasses>(2)
val jobs = (1..2).map {
launch {
client.webSocket(GLASSES_WS_PATH) {
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
opened.send(Unit)
while (true) {
val frame = incoming.receive()
@@ -115,12 +122,9 @@ class GlassesServerTest {
}
@Test
fun downloadProgressUpdatesHubPerFileMap() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
fun downloadProgressUpdatesHubPerFileMap() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)))
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE)))
}
@@ -131,12 +135,9 @@ class GlassesServerTest {
}
@Test
fun extendedGlassesStatusUpdatesHubFields() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
fun extendedGlassesStatusUpdatesHubFields() = runServer { port, hub ->
val client = wsClient()
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
send(
json.encodeToString(
GlassesToHost.serializer(),
@@ -163,6 +164,21 @@ class GlassesServerTest {
assertEquals("0.2.0", hub.appVersion.value)
}
// ---------- помощники ----------
private inline fun runServer(crossinline block: suspend (port: Int, hub: GlassesHub) -> Unit) {
val (server, port) = startServer()
try {
kotlinx.coroutines.runBlocking { block(port, server.hub) }
} finally {
server.stop()
}
}
private fun wsClient() = io.ktor.client.HttpClient {
install(WebSockets)
}
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (!condition()) {
@@ -0,0 +1,38 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Тесты тулов ассистента — чистая логика без БД и Android.
*/
class AgentToolsTest {
@Test
fun `тул времени возвращает ISO 8601 с датой и временем`() {
val result = TimeTool.invoke("{}")
// 2026-08-22T03:14:05+03:00 — содержит дату, время и смещение
assertTrue(
result.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")),
"не ISO 8601: $result",
)
assertTrue(result.startsWith("20"), "год не 20xx: $result")
}
@Test
fun `реестр содержит тулу времени с описанием`() {
assertTrue(AgentTools.ALL.any { it.name == "get_current_time" })
val desc = AgentTools.describeAll()
assertTrue(desc.contains("get_current_time"))
assertTrue(desc.contains("ISO 8601"))
}
@Test
fun `тула времени не зависит от аргументов`() {
// Пустой JSON и мусор — один результат (тула без параметров)
val a = TimeTool.invoke("{}")
val b = TimeTool.invoke("{\"x\":1}")
assertEquals(a.take(19), b.take(19)) // одинаковые дата и время в пределах секунды
}
}