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