Десктоп-клиент: Compose-UI, сессии, SQLite-кэш, голос, живые тесты

- settings/: settings.json (агенты, тема, clientId), атомарная запись
- persistence/: ConversationMetaRepository — группы, lastSeen, превью,
  watermark синхронизации (synced_at через PRAGMA table_info)
- session/: AgentRegistry (параллельное подключение, деградация в офлайн),
  AgentConnection (журнал + мета + живой список диалогов conversationsFlow,
  refresh по SSE-потоку), ChatSession (history + backfill + live + preview,
  дедуп по id, 1 мс шаг watermark, syncMutex), LiveTurn
- UI: рейка агентов, список диалогов (живое обновление), чат со стримингом,
  композер (Enter отправляет, Shift+Enter переносит, Ctrl+M — голос),
  окна «Агенты»/«Группы»/«Новый диалог», тёмная и светлая темы
- voice/: mic-api → Silero VAD → Vosk на одном выделенном потоке;
  VoiceNative.warmUp() до skia (иначе SIGSEGV), модель в артефакте
- markdown/: вендоренный рендер из ai/assistent
- ошибки в баннер через AppState.reportError (CancellationException игнорируется)
- тесты 103 (реальная SQLite + фейковый транспорт); против живого агента —
  RealServerIT, RealAppStateIT, RealSpeechIT
- LIVE-RUN-NOTES.md: живой прогон в GUI и найденные/починенные баги
This commit is contained in:
2026-09-28 10:08:10 +03:00
parent 27f349550c
commit 2c71542b13
53 changed files with 9147 additions and 0 deletions
@@ -0,0 +1,162 @@
package pw.binom.agentik.desktop.ui
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.jupiter.api.Assumptions.assumeTrue
import pw.binom.agentik.desktop.model.UiMessage
import pw.binom.agentik.desktop.session.AgentConnection
import pw.binom.agentik.desktop.session.AgentRegistry
import pw.binom.agentik.desktop.session.AgentStatus
import pw.binom.agentik.desktop.settings.AgentConfig
import pw.binom.agentik.desktop.settings.SettingsRepository
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import java.net.HttpURLConnection
import java.net.URI
import java.nio.file.Files
/**
* Приложение целиком (без пикселей) против ЖИВОГО `:standalone`-агента:
* настройки → реестр → [AppState] → диалог с настоящей LLM.
*
* Это замена GUI-прогону, когда vision-сервис недоступен: UI у нас — чистая
* функция от [AppState], а сам рендер уже проверен `ThemeRenderTest`.
* Проверяем то, что ломается только в жизни: создание диалога на сервере,
* отправку, живой ответ модели, попадание реплик в локальный кэш (историю),
* снятие «непрочитанного» после открытия.
*
* Адрес — `AGENTIK_E2E_URL` или `http://127.0.0.1:8080/agentik`; если сервер
* не поднят, тест **пропускается** (`assumeTrue`), как [RealServerIT].
*/
class RealAppStateIT {
private var registry: AgentRegistry? = null
private var state: AppState? = null
// Свой scope, а не coroutineContext из runBlocking: у реестра/сессии живут
// вечные коллекторы (SSE, listFlow), и runBlocking ждал бы их вечно.
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@AfterTest
fun tearDown() {
runCatching { state?.shutdown() }
runCatching { registry?.close() }
scope.cancel()
}
private fun url(): String =
System.getenv("AGENTIK_E2E_URL")?.takeIf { it.isNotBlank() }
?: "http://127.0.0.1:8080/agentik"
private fun reachable(url: String): Boolean = runCatching {
(URI("$url").toURL().openConnection() as HttpURLConnection).run {
connectTimeout = 1_500
readTimeout = 1_500
requestMethod = "GET"
responseCode in 200..499
}
}.getOrDefault(false)
private suspend fun await(
what: String,
timeoutMs: Long = 10_000,
condition: suspend () -> Boolean,
) {
val ok = withTimeoutOrNull(timeoutMs) {
while (!condition()) delay(20)
true
}
check(ok == true) { "не дождались: $what (${timeoutMs}мс)" }
}
@Test
fun `app state creates a dialog and gets a live answer from the real agent`() = runBlocking {
val base = url()
assumeTrue(reachable(base), "standalone-агент недоступен на $base — тест пропущен")
val tmp = Files.createTempDirectory("agentik-appstate")
val reg = AgentRegistry.create(
settingsRepo = SettingsRepository(tmp.resolve("settings.json")),
scope = scope,
)
registry = reg
val st = AppState(registry = reg, scope = scope)
state = st
st.start()
st.addAgent(baseUrl = base, nameOverride = "Живой агент")
await("агент появился в настройках") { st.agents.isNotEmpty() }
val agentId = st.agents.first().id
await("агент подключился", timeoutMs = 20_000) {
st.statusesState[agentId] is AgentStatus.Online
}
st.selectAgent(agentId)
await("список диалогов подтянулся с сервера") { st.conversations.isNotEmpty() }
st.newConversation(agentId)
await("диалог открылся") { st.activeConversationId != null }
val convId = st.activeConversationId!!
st.send("Ответь ровно одним словом: два плюс два?")
await("пришёл ответ ассистента", timeoutMs = 240_000) {
st.history.any { it is UiMessage.Assistant }
}
val assistant = st.history.filterIsInstance<UiMessage.Assistant>().last()
val assistantText = assistant.content.filterIsInstance<pw.binom.agentik.desktop.model.UiContent.Text>()
.joinToString(" ") { it.body }
assertTrue(assistantText.isNotBlank(), "ответ ассистента пустой")
assertTrue(
st.history.any { it is UiMessage.User },
"реплика пользователя должна быть в истории (из локального кэша)",
)
// Превью денормализовано в локальную мету (для строки списка).
val preview = reg.connection(agentId)!!.meta.meta(convId)?.preview
assertNotNull(preview, "preview диалога должен записаться локально")
// Открытие диалога помечает его просмотренным: непрочитанных нет.
val lastSeen = reg.connection(agentId)!!.meta.meta(convId)?.lastSeen
assertNotNull(lastSeen, "lastSeen должен выставиться при открытии")
// История диалога переживает повторное открытие (тот же локальный кэш).
st.openConversation(convId)
await("история перезагрузилась из кэша") { st.history.isNotEmpty() }
assertTrue(
st.history.filterIsInstance<UiMessage.Assistant>().isNotEmpty(),
"после повторного открытия ответ должен остаться в истории",
)
}
@Test
fun `live dialog list arrives by itself right after connect`() = runBlocking {
val base = url()
assumeTrue(reachable(base), "standalone-агент недоступен на $base — тест пропущен")
val tmp = Files.createTempDirectory("agentik-convflow")
val cfg = AgentConfig.create(baseUrl = base, nameOverride = "Живой агент")
val conn = AgentConnection.open(
config = cfg,
clientId = "agentik-desktop-it",
scope = scope,
dbPath = tmp.resolve("agentik.db").toString(),
)
// Seed внутри `AgentikAgent` асинхронный: список обязан приехать сам,
// без единого `refresh()` со стороны UI. Именно этого не хватало —
// приложение показывало пустой список диалогов.
await("живой список диалогов доехал сам", timeoutMs = 15_000) {
conn.conversationsFlow.value.isNotEmpty()
}
conn.close()
}
}
@@ -0,0 +1,154 @@
package pw.binom.agentik.desktop.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.ui.theme.AgentikColors
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
import pw.binom.agentik.desktop.ui.theme.lightAgentikColors
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Палитра и реальный рендер Compose без открытия окна.
*
* Зачем: светлая тема до сих пор проверялась только глазами по скриншоту, а
* markdown-код-блок изначально хардкодил тёмный фон (`#1E1E1E`), из-за чего в
* светлой теме выглядел чужеродно. Здесь тема прогоняется через настоящий
* оффскрин-рендер, а цвета сверяются с проектными токенами.
*/
class ThemeRenderTest {
// ── токены (THEMES.md / sketches/style.css) ────────────────────────────
@Test
fun darkPaletteMatchesDesignTokens() {
val c = darkAgentikColors
assertEquals(Color(0xFF121218), c.background)
assertEquals(Color(0xFF17212B), c.panel)
assertEquals(Color(0xFF1C2733), c.panelElevated)
assertEquals(Color(0xFF6AB2F2), c.accent)
assertEquals(Color(0xFFEBEBEB), c.text)
assertEquals(Color(0xFF768C9E), c.textDim)
assertEquals(Color(0xFFEC3942), c.error)
assertEquals(Color(0xFF2B5278), c.userBubble)
}
@Test
fun lightPaletteKeepsSemanticSlots() {
val c = lightAgentikColors
assertEquals(Color(0xFFF5F6F8), c.background)
assertEquals(Color(0xFFFFFFFF), c.panel)
assertEquals(Color(0xFF2A6FB5), c.accent)
assertEquals(Color(0xFF1A1D23), c.text)
// светлый фон обязан быть светлым: контраст к тексту
assertTrue(luminance(c.background) > luminance(c.text), "фон светлее текста")
assertTrue(luminance(c.panel) > luminance(c.panelElevated), "panel светлее panelElevated")
assertTrue(luminance(c.botBubble) > luminance(c.text), "бот-бабл светлее текста")
}
@Test
fun opaqueSlotsStayOpaqueAndScrimTransparent() {
for (c in listOf(darkAgentikColors, lightAgentikColors)) {
assertEquals(1f, c.background.alpha)
assertEquals(1f, c.panel.alpha)
assertEquals(1f, c.text.alpha)
assertEquals(1f, c.accent.alpha)
// scrim/selection — прозрачные по замыслу
assertTrue(c.scrim.alpha < 1f)
assertTrue(c.selection.alpha < 1f)
}
}
// ── рендер ─────────────────────────────────────────────────────────────
@OptIn(ExperimentalTestApi::class)
@Test
fun codeBlockPaintsThemeCodeBackgroundInLightTheme() = runComposeUiTest {
setContent {
AgentikTheme("light") {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
Column {
renderMarkdownSegments(
content = "```kotlin\nval x = 1\n```",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
}
}
}
}
val pixels = onRoot().captureToImage().toPixelMap()
val histogram = HashMap<Color, Int>()
for (y in 0 until pixels.height) {
for (x in 0 until pixels.width) {
val color = pixels[x, y]
histogram[color] = (histogram[color] ?: 0) + 1
}
}
val top = histogram.entries.sortedByDescending { it.value }.take(4)
.joinToString { "#${it.key.toHexArgb()}×${it.value}" }
assertTrue(
histogram.containsKey(lightAgentikColors.codeBackground),
"код-блок должен краситься в codeBackground светлой темы, а покрасился в: $top",
)
assertFalse(
histogram.containsKey(Color(0xFF1E1E1E)),
"тёмный хардкод код-фона протёк в светлую тему: $top",
)
}
@OptIn(ExperimentalTestApi::class)
@Test
fun darkThemeStillRendersCodeBlockDark() = runComposeUiTest {
setContent {
AgentikTheme("dark") {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
Column {
renderMarkdownSegments(
content = "```kotlin\nval x = 1\n```",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
}
}
}
}
val pixels = onRoot().captureToImage().toPixelMap()
var found = false
for (y in 0 until pixels.height) {
for (x in 0 until pixels.width) {
if (pixels[x, y] == darkAgentikColors.codeBackground) {
found = true
break
}
}
}
assertTrue(found, "в тёмной теме код-блок должен краситься в dark codeBackground")
}
private fun luminance(color: Color): Float =
0.2126f * color.red + 0.7152f * color.green + 0.0722f * color.blue
private fun Color.toHexArgb(): String {
val a = (alpha * 255).toInt()
val r = (red * 255).toInt()
val g = (green * 255).toInt()
val b = (blue * 255).toInt()
return "%02X%02X%02X%02X".format(a, r, g, b)
}
}