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 и найденные/починенные баги
196 lines
8.7 KiB
Kotlin
196 lines
8.7 KiB
Kotlin
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")
|
||
}
|
||
}
|