Десктоп-клиент: 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,195 @@
package pw.binom.agentik.desktop.session
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assumptions.assumeTrue
import pw.binom.agentik.desktop.model.UiContent
import pw.binom.agentik.desktop.model.UiMessage
import pw.binom.agentik.desktop.settings.AgentConfig
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import java.nio.file.Files
/**
* Реальная интеграция с ЖИВЫМ `:standalone`-сервером (Qwen через vLLM),
* а не с in-memory-эхо-агентом из [AgentConnectionE2ETest].
*
* Адрес берётся из env `AGENTIK_E2E_URL`, по умолчанию
* `http://127.0.0.1:8080/agentik`. Если сервер не поднят — тест
* **пропускается** (`assumeTrue`), поэтому `./gradlew jvmTest` остаётся
* зелёным без запущенного агента, а с ним — проверяет настоящий контракт:
* создать диалог → отправить → дождаться живого ответа LLM → история в
* локальном SQLite → повторное открытие не плодит дублей.
*
* БД уводится в temp-папку, пользовательский `~/.agentik-desktop` не
* трогается.
*/
class RealServerIT {
private val connection = mutableListOf<AgentConnection>()
@AfterTest
fun tearDown() {
connection.forEach { runCatching { it.close() } }
connection.clear()
}
private fun url(): String =
System.getenv("AGENTIK_E2E_URL")?.takeIf { it.isNotBlank() }
?: "http://127.0.0.1:8080/agentik"
private fun connect(scope: kotlinx.coroutines.CoroutineScope, db: String): AgentConnection? =
runCatching {
AgentConnection.open(
config = AgentConfig.create(baseUrl = url()),
clientId = "agentik-desktop-real-e2e",
scope = scope,
dbPath = db,
)
}.getOrNull()
@Test
fun `live server answers and the turn lands in the local cache`() = runBlocking {
val dir = Files.createTempDirectory("agentik-real-e2e")
val db = dir.resolve("agentik.db").toString()
val conn = connect(this, db)
assumeTrue(conn != null, "standalone-сервер недоступен на ${url()} — тест пропущен")
conn!!
connection += conn
val conv = conn.createConversation(temp = false)
assertNotNull(conv, "createConversation")
val session = assertNotNull(conn.openSession(conv.id), "openSession")
try {
session.start()
session.send("Ответь ровно одним словом: привет")
await(timeoutMs = 120_000) {
session.history.value.size >= 2 && !session.isStreaming
}
val history = session.history.value
assertEquals(2, history.size, "user + assistant: $history")
val user = history[0] as UiMessage.User
assertEquals("Ответь ровно одним словом: привет", (user.content.single() as UiContent.Text).body)
val bot = history[1] as UiMessage.Assistant
val answer = (bot.content.single() as UiContent.Text).body
assertTrue(answer.isNotBlank(), "LLM вернул пустой ответ")
// Локальный кэш (offline-история) — на диске.
val cached = conn.journalCache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
assertEquals(2, cached.size, "cache records")
// Превью + watermark денормализованы из ответа.
assertNotNull(conn.meta.meta(conv.id)?.preview)
assertNotNull(conn.meta.meta(conv.id)?.syncedAt)
// Список диалогов от сервера содержит созданный.
await(timeoutMs = 20_000) {
conn.uiConversations().any { it.id == conv.id }
}
// Переоткрытие читает из кэша и НЕ дублирует бэкфиллом.
val again = assertNotNull(conn.openSession(conv.id))
try {
again.start()
assertEquals(2, again.history.value.size, "reopen must not duplicate")
} finally {
again.close()
}
} finally {
session.close()
}
}
/**
* Сценарий «перезапуск клиента»: закрываем соединение и открываем
* заново на той же БД — история должна читаться из локального кэша,
* а не теряться.
*/
@Test
fun `history survives a reconnect on the same database`() = runBlocking {
val dir = Files.createTempDirectory("agentik-real-e2e-reconnect")
val db = dir.resolve("agentik.db").toString()
val convId: String
run {
val conn = connect(this, db)
assumeTrue(conn != null, "standalone-сервер недоступен на ${url()} — тест пропущен")
conn!!
connection += conn
val conv = assertNotNull(conn.createConversation(temp = false))
val session = assertNotNull(conn.openSession(conv.id))
session.start()
session.send("Скажи 'ок'")
await(timeoutMs = 120_000) { session.history.value.size >= 2 && !session.isStreaming }
convId = conv.id
session.close()
conn.close()
connection -= conn
}
val conn2 = assertNotNull(connect(this, db), "повторное подключение")
connection += conn2
val session2 = assertNotNull(conn2.openSession(convId), "openSession после перезапуска")
try {
session2.start()
await(timeoutMs = 30_000) { session2.history.value.size >= 2 }
assertEquals(2, session2.history.value.size, "история из локального кэша после реконнекта")
} finally {
session2.close()
}
}
/**
* Регрессия на реальный баг: `POST /conversations/{id}/messages` на
* сервере отвечает не сразу, а по завершении всего хода агента. Длинный
* ответ LLM (десятки секунд) обрывался дефолтным request-timeout'ом
* клиента (15000 мс) — ход умирал на клиенте, продолжая жить на сервере.
* Клиент обязан ставить на этот запрос infinite-timeout
* (`ConversationClient.send` → `noReadTimeout()`).
*/
@Test
fun `long llm answer outlives the default client request timeout`() = runBlocking {
val dir = Files.createTempDirectory("agentik-real-e2e-long")
val conn = connect(this, dir.resolve("agentik.db").toString())
assumeTrue(conn != null, "standalone-сервер недоступен на ${url()} — тест пропущен")
conn!!
connection += conn
val conv = assertNotNull(conn.createConversation(temp = false))
val session = assertNotNull(conn.openSession(conv.id))
try {
session.start()
session.send("Напиши связный текст примерно на 300 слов про историю SQLite. Без списков и заголовков.")
await(timeoutMs = 300_000) {
session.history.value.size >= 2 && !session.isStreaming
}
val answer = (session.history.value[1] as UiMessage.Assistant)
.content.filterIsInstance<UiContent.Text>().joinToString("\n") { it.body }
assertTrue(
answer.length > 500,
"ожидался длинный ответ (иначе тест не проверяет таймаут), а пришло ${answer.length} символов: $answer",
)
} finally {
session.close()
}
}
private suspend fun await(timeoutMs: Long = 5_000, condition: suspend () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (condition()) return
delay(50)
}
assertTrue(condition(), "condition not met within ${timeoutMs}ms")
}
}