Десктоп-клиент: 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,277 @@
package pw.binom.agentik.desktop.voice
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.fail
import kotlin.test.assertTrue
/**
* Состояния и колбэки [VoiceController] на фейковых источнике аудио и
* распознавателе — без микрофона и без нативных библиотек.
*
* Время настоящее (`runBlocking` + ожидание состояния), а не `runTest` с
* виртуальным: контроллер считает на выделенном потоке `Dispatchers.Default`.
*/
class VoiceControllerTest {
private val chunk = ByteArray(4) { it.toByte() }
private fun finiteMic(vararg chunks: ByteArray): MicSource =
MicSource { _, _ -> flow { chunks.forEach { emit(it) } } }
private fun endlessMic(chunk: ByteArray = ByteArray(4) { 1 }): MicSource = MicSource { _, _ ->
flow {
while (true) {
emit(chunk)
delay(5)
}
}
}
private class FakeRecognizer(
private val partials: List<String> = emptyList(),
private val finalText: String = "",
override val speechDetected: Boolean = true,
) : VoiceRecognizer {
val fed = mutableListOf<ByteArray>()
var finished = false
var closed = false
private var partialCalls = 0
override fun feed(pcm: ByteArray) {
fed += pcm
}
override fun partial(): String? {
if (partials.isEmpty()) return null
return partials[minOf(partialCalls++, partials.size - 1)]
}
override fun finish(): String {
finished = true
return finalText
}
override fun close() {
closed = true
}
}
private class Recorder {
val states = mutableListOf<VoiceState>()
val partials = mutableListOf<String>()
val finals = mutableListOf<String>()
val errors = mutableListOf<String>()
}
private fun controller(
scope: CoroutineScope,
mic: MicSource,
recognizer: VoiceRecognizer,
rec: Recorder,
) = VoiceController(
scope = scope,
recognizers = VoiceRecognizerFactory { recognizer },
mic = mic,
onState = { rec.states += it },
onPartial = { rec.partials += it },
onFinal = { rec.finals += it },
onError = { rec.errors += it },
)
@Test
fun `stop delivers final text and closes recognizer`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(partials = listOf("при"), finalText = "привет")
val voice = controller(this, endlessMic(), recognizer, rec)
voice.start()
await("микрофон начал писать") { voice.state == VoiceState.RECORDING }
await("пришёл промежуточный текст") { rec.partials.isNotEmpty() }
assertEquals(listOf("при"), rec.partials)
voice.stop()
awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() }
awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE }
assertEquals(listOf("привет"), rec.finals)
assertTrue(recognizer.finished, "finish() должен быть вызван")
assertTrue(recognizer.closed, "recognizer должен быть закрыт")
assertEquals(listOf(VoiceState.PREPARING, VoiceState.RECORDING, VoiceState.RECOGNIZING, VoiceState.IDLE), rec.states)
}
@Test
fun `cancelled recording emits nothing`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(partials = listOf("при"), finalText = "привет")
val voice = controller(this, endlessMic(), recognizer, rec)
voice.start()
await("микрофон начал писать") { voice.state == VoiceState.RECORDING }
await("пришёл промежуточный текст") { rec.partials.isNotEmpty() }
voice.cancel()
awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE }
delay(50)
assertTrue(rec.finals.isEmpty(), "после cancel финал не отдаётся: ${rec.finals}")
assertEquals(VoiceState.IDLE, voice.state)
}
@Test
fun `microphone ending on its own finishes the turn`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(finalText = "конец")
val voice = controller(this, finiteMic(chunk, chunk), recognizer, rec)
voice.start()
awaitWithDump("финал отдан без stop()", rec, voice) { rec.finals.isNotEmpty() }
assertEquals(listOf("конец"), rec.finals)
assertEquals(VoiceState.IDLE, voice.state)
assertTrue(recognizer.closed)
}
@Test
fun `microphone chunks reach the recognizer`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(finalText = "ок")
val voice = controller(this, finiteMic(chunk, chunk, chunk), recognizer, rec)
voice.start()
awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() }
assertEquals(3, recognizer.fed.size)
assertEquals(chunk.toList(), recognizer.fed[0].toList())
}
@Test
fun `empty chunks are skipped`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(finalText = "ок")
val voice = controller(this, finiteMic(ByteArray(0), chunk, ByteArray(0)), recognizer, rec)
voice.start()
awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() }
assertEquals(1, recognizer.fed.size)
}
@Test
fun `silence is not turned into text`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(finalText = "", speechDetected = false)
val voice = controller(this, finiteMic(chunk, chunk), recognizer, rec)
voice.start()
awaitWithDump("ошибка про тишину", rec, voice) { rec.errors.isNotEmpty() }
assertEquals(listOf("Речь не распознана"), rec.errors)
assertTrue(rec.finals.isEmpty())
assertEquals(VoiceState.IDLE, voice.state)
}
@Test
fun `empty final with speech detected is silently dropped`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(finalText = " ", speechDetected = true)
val voice = controller(this, finiteMic(chunk), recognizer, rec)
voice.start()
awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE && recognizer.finished }
delay(50)
assertTrue(rec.finals.isEmpty())
assertTrue(rec.errors.isEmpty(), "речь была, но текста нет — это не ошибка: ${rec.errors}")
}
@Test
fun `microphone failure surfaces as error`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer()
val mic = MicSource { _, _ ->
flow<ByteArray> { throw IllegalStateException("no line") }
}
val voice = controller(this, mic, recognizer, rec)
voice.start()
awaitWithDump("ошибка микрофона", rec, voice) { rec.errors.isNotEmpty() }
assertEquals(listOf("Микрофон недоступен: no line"), rec.errors)
assertEquals(VoiceState.IDLE, voice.state)
assertTrue(recognizer.closed, "распознаватель закрывается и при ошибке микрофона")
}
@Test
fun `recognizer creation failure surfaces as error`() = runBlocking {
val rec = Recorder()
val voice = VoiceController(
scope = this,
recognizers = VoiceRecognizerFactory { throw IllegalStateException("model not found") },
mic = finiteMic(chunk),
onState = { rec.states += it },
onError = { rec.errors += it },
)
voice.start()
await("ошибка создания") { rec.errors.isNotEmpty() }
assertEquals(listOf("Не удалось запустить распознавание: model not found"), rec.errors)
assertEquals(VoiceState.IDLE, voice.state)
}
@Test
fun `toggle starts then stops`() = runBlocking {
val rec = Recorder()
val recognizer = FakeRecognizer(finalText = "готово")
val voice = controller(this, endlessMic(), recognizer, rec)
voice.toggle()
await("пишем") { voice.state == VoiceState.RECORDING }
assertTrue(voice.isActive)
voice.toggle()
awaitWithDump("финал", rec, voice) { rec.finals.isNotEmpty() }
assertTrue(rec.finals.contains("готово"))
}
@Test
fun `stop before start does nothing`() = runBlocking {
val rec = Recorder()
val voice = controller(this, endlessMic(), FakeRecognizer(), rec)
voice.stop()
voice.cancel()
delay(30)
assertEquals(VoiceState.IDLE, voice.state)
assertTrue(rec.states.isEmpty(), "никаких переходов не ожидается: ${rec.states}")
}
/** Ожидание условия с реальным временем (см. заметку про ksqlite/Dispatchers). */
private suspend fun await(what: String, timeoutMs: Long = 5_000, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (condition()) return
delay(5)
}
fail("не дождались: $what")
}
/** То же, но с дампом состояния — чтобы падение было диагностируемым. */
private suspend fun awaitWithDump(what: String, rec: Recorder, voice: VoiceController, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + 5_000
while (System.currentTimeMillis() < deadline) {
if (condition()) return
delay(5)
}
fail(
"не дождались: $what; state=${voice.state} active=${voice.isActive} " +
"states=${rec.states} partials=${rec.partials} finals=${rec.finals} errors=${rec.errors}"
)
}
}