2c71542b13
- 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 и найденные/починенные баги
163 lines
7.4 KiB
Kotlin
163 lines
7.4 KiB
Kotlin
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()
|
||
}
|
||
}
|