Десктоп-клиент: 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()
}
}