Files
agentik-desktop/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/LiveTurnTest.kt
T

217 lines
7.9 KiB
Kotlin

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<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(
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<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(
OnlineEvent.Working(at()),
OnlineEvent.StartResponse(at(), OnlineEvent.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(
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<LiveBlock.Tool>().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<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(
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<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(
OnlineEvent.Working(at()),
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.IMAGE),
OnlineEvent.AppendImage(at(), body = bytes, mime = "image/png"),
OnlineEvent.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(
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("…"))
}
}