Десктоп-клиент: 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,209 @@
package pw.binom.agentik.desktop.session
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
import pw.binom.agentik.outbox.Event
import kotlin.time.Clock
import kotlin.time.Instant
class LiveTurnTest {
private val now = Clock.System.now()
private fun at() = Instant.fromEpochMilliseconds(now.toEpochMilliseconds())
private fun LiveTurn.feed(vararg events: Event) {
events.forEach { apply(it) }
}
@Test
fun `full happy path accumulates reasoning and answer`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartReasoning(at()),
Event.AppendText(at(), "сначала "),
Event.AppendText(at(), "подумаем"),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "Привет, "),
Event.AppendText(at(), "мир!"),
Event.End(at()),
)
assertEquals(TurnPhase.DONE, turn.phase)
val blocks = turn.blocks
assertEquals(2, blocks.size)
val reasoning = assertIs<LiveBlock.Reasoning>(blocks[0])
assertEquals("сначала подумаем", reasoning.text.toString())
val answer = assertIs<LiveBlock.Answer>(blocks[1])
assertEquals("Привет, мир!", answer.text.toString())
assertEquals("Привет, мир!", turn.answerText())
}
@Test
fun `tool call and result are stitched into one block`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.ToolCall(at(), id = "t1", title = "grep", toolName = "grep", toolArgs = "{\"q\":\"x\"}"),
Event.ToolResult(at(), toolCallId = "t1", toolName = "grep", result = "found 3"),
Event.End(at()),
)
val tools = turn.blocks.filterIsInstance<LiveBlock.Tool>()
assertEquals(1, tools.size)
assertEquals("t1", tools[0].callId)
assertEquals("grep", tools[0].toolName)
assertEquals("{\"q\":\"x\"}", tools[0].argsJson)
assertEquals("found 3", tools[0].result)
assertEquals(null, tools[0].failed)
}
@Test
fun `orphan tool result creates a block`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.ToolResult(at(), toolCallId = "missing", toolName = null, result = "ok"),
)
val tool = turn.blocks.filterIsInstance<LiveBlock.Tool>().single()
assertEquals("missing", tool.callId)
assertEquals("(unknown)", tool.toolName)
assertEquals("ok", tool.result)
}
@Test
fun `tool failed marks the block`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.ToolCall(at(), id = "t9", title = null, toolName = "bash", toolArgs = "{}"),
Event.ToolFailed(at(), toolCallId = "t9", toolName = "bash", message = "boom", durationMs = 5),
)
val tool = turn.blocks.filterIsInstance<LiveBlock.Tool>().single()
assertEquals("boom", tool.failed)
}
@Test
fun `error appends failure block and fails the turn`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "partial"),
Event.Error(at(), message = "llm down", code = "E500"),
)
assertEquals(TurnPhase.FAILED, turn.phase)
val failure = turn.blocks.filterIsInstance<LiveBlock.Failure>().single()
assertEquals("llm down", failure.message)
assertEquals("E500", failure.code)
// Частичный ответ остаётся в блоках — UI решает, показывать ли его.
assertEquals("partial", turn.answerText())
}
@Test
fun `interrupted turn is terminal and keeps partial text`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "не до конца"),
Event.Interrupted(at()),
)
assertEquals(TurnPhase.INTERRUPTED, turn.phase)
assertTrue(turn.phase.isTerminal)
assertEquals("не до конца", turn.answerText())
}
@Test
fun `text after tool call starts a new answer block`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "до"),
Event.ToolCall(at(), id = "t1", title = null, toolName = "grep", toolArgs = "{}"),
Event.AppendText(at(), "после"),
)
val answers = turn.blocks.filterIsInstance<LiveBlock.Answer>()
assertEquals(2, answers.size)
assertEquals("до", answers[0].text.toString())
assertEquals("после", answers[1].text.toString())
assertEquals("допосле", turn.answerText())
}
@Test
fun `image response produces image block`() {
val bytes = byteArrayOf(1, 2, 3)
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.IMAGE),
Event.AppendImage(at(), body = bytes, mime = "image/png"),
Event.End(at()),
)
assertTrue(turn.blocks.filterIsInstance<LiveBlock.Answer>().isEmpty())
val image = turn.blocks.filterIsInstance<LiveBlock.Image>().single()
assertEquals("image/png", image.mime)
assertEquals(3, image.data.size)
}
@Test
fun `working resets the previous turn`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.StartResponse(at(), Event.ResponseType.TEXT),
Event.AppendText(at(), "старое"),
Event.End(at()),
)
turn.feed(Event.Working(at()), Event.AppendText(at(), "новое"))
assertEquals(1, turn.blocks.size)
assertEquals("новое", turn.answerText())
}
@Test
fun `append without explicit start response still streams as answer`() {
val turn = LiveTurn()
turn.feed(Event.Working(at()), Event.AppendText(at(), "без старта"))
assertEquals(TurnPhase.RESPONDING, turn.phase)
assertEquals("без старта", turn.answerText())
}
@Test
fun `onChange fires on mutation`() {
val turn = LiveTurn()
var count = 0
turn.onChange = { count++ }
turn.feed(Event.Working(at()), Event.AppendText(at(), "x"))
assertEquals(2, count)
}
@Test
fun `preview text collapses whitespace and truncates`() {
val turn = LiveTurn()
turn.feed(
Event.Working(at()),
Event.AppendText(at(), "Первая строка\n\nвторая строка "),
)
assertEquals("Первая строка вторая строка", turn.previewText())
val long = LiveTurn()
long.feed(Event.Working(at()), Event.AppendText(at(), "слово ".repeat(60)))
val preview = long.previewText(maxLength = 30)
assertTrue(preview.length <= 31)
assertTrue(preview.endsWith("…"))
}
}