Files
agentik-desktop/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/RealAppStateIT.kt
T
subochev 2c71542b13 Десктоп-клиент: 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 и найденные/починенные баги
2026-09-28 10:08:10 +03:00

163 lines
7.4 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}