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