Десктоп-клиент: 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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user