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 pw.binom.agentik.outbox.OnlineEvent 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: Any) { events.forEach { when (it) { is Event -> apply(it) is OnlineEvent -> applyOnline(it) else -> error("unexpected event: $it") } } } @Test fun `full happy path accumulates reasoning and answer`() { val turn = LiveTurn() turn.feed( OnlineEvent.Working(at()), OnlineEvent.StartReasoning(at()), OnlineEvent.AppendText(at(), "сначала "), OnlineEvent.AppendText(at(), "подумаем"), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT), OnlineEvent.AppendText(at(), "Привет, "), OnlineEvent.AppendText(at(), "мир!"), OnlineEvent.End(at()), ) assertEquals(TurnPhase.DONE, turn.phase) val blocks = turn.blocks assertEquals(2, blocks.size) val reasoning = assertIs(blocks[0]) assertEquals("сначала подумаем", reasoning.text.toString()) val answer = assertIs(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( OnlineEvent.Working(at()), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT), Event.ToolCall(at(), id = "t1", title = "grep", toolName = "grep", toolArgs = "{\"q\":\"x\"}"), Event.ToolResult(at(), toolCallId = "t1", toolName = "grep", result = "found 3"), OnlineEvent.End(at()), ) val tools = turn.blocks.filterIsInstance() 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( OnlineEvent.Working(at()), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT), Event.ToolResult(at(), toolCallId = "missing", toolName = null, result = "ok"), ) val tool = turn.blocks.filterIsInstance().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( OnlineEvent.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().single() assertEquals("boom", tool.failed) } @Test fun `error appends failure block and fails the turn`() { val turn = LiveTurn() turn.feed( OnlineEvent.Working(at()), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT), OnlineEvent.AppendText(at(), "partial"), Event.Error(at(), message = "llm down", code = "E500"), ) assertEquals(TurnPhase.FAILED, turn.phase) val failure = turn.blocks.filterIsInstance().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( OnlineEvent.Working(at()), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT), OnlineEvent.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( OnlineEvent.Working(at()), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT), OnlineEvent.AppendText(at(), "до"), Event.ToolCall(at(), id = "t1", title = null, toolName = "grep", toolArgs = "{}"), OnlineEvent.AppendText(at(), "после"), ) val answers = turn.blocks.filterIsInstance() 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( OnlineEvent.Working(at()), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.IMAGE), OnlineEvent.AppendImage(at(), body = bytes, mime = "image/png"), OnlineEvent.End(at()), ) assertTrue(turn.blocks.filterIsInstance().isEmpty()) val image = turn.blocks.filterIsInstance().single() assertEquals("image/png", image.mime) assertEquals(3, image.data.size) } @Test fun `working resets the previous turn`() { val turn = LiveTurn() turn.feed( OnlineEvent.Working(at()), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT), OnlineEvent.AppendText(at(), "старое"), OnlineEvent.End(at()), ) turn.feed(OnlineEvent.Working(at()), OnlineEvent.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(OnlineEvent.Working(at()), OnlineEvent.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(OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "x")) assertEquals(2, count) } @Test fun `preview text collapses whitespace and truncates`() { val turn = LiveTurn() turn.feed( OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "Первая строка\n\nвторая строка "), ) assertEquals("Первая строка вторая строка", turn.previewText()) val long = LiveTurn() long.feed(OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "слово ".repeat(60))) val preview = long.previewText(maxLength = 30) assertTrue(preview.length <= 31) assertTrue(preview.endsWith("…")) } }