From 6b7b5ee57bc00eeded3384e04ffc927c6eea6ad8 Mon Sep 17 00:00:00 2001 From: subochev Date: Wed, 30 Sep 2026 12:41:10 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B2=D1=8B=D0=BD=D0=BE=D1=81=D0=B8=D1=82=20?= =?UTF-8?q?=D1=82=D0=B8=D0=BF=D1=8B=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B2=20:content-api=20=D0=B8=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B4=D0=B5=D0=BB=D1=8F=D0=B5=D1=82=20=D0=BF=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pw/binom/agentik/desktop/model/Mappers.kt | 2 +- .../persistence/ConversationMetaRepository.kt | 2 +- .../desktop/session/AgentSyncEngine.kt | 2 +- .../agentik/desktop/session/ChatSession.kt | 16 ++++---- .../binom/agentik/desktop/session/LiveTurn.kt | 37 +++++++++++++------ .../agentik/desktop/model/MappersTest.kt | 2 +- .../desktop/net/ConnectionCheckerTest.kt | 4 +- .../desktop/session/AgentConnectionE2ETest.kt | 27 +++++++++----- .../desktop/session/AgentSyncEngineTest.kt | 10 +++-- .../desktop/session/ChatSessionTest.kt | 20 ++++++---- .../agentik/desktop/session/LiveTurnTest.kt | 36 +++++++++--------- 11 files changed, 95 insertions(+), 63 deletions(-) diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/model/Mappers.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/model/Mappers.kt index 4883b62..85cacd2 100644 --- a/src/jvmMain/kotlin/pw/binom/agentik/desktop/model/Mappers.kt +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/model/Mappers.kt @@ -3,7 +3,7 @@ package pw.binom.agentik.desktop.model import pw.binom.agentik.desktop.persistence.MetaRow import pw.binom.agentik.desktop.session.LiveBlock import pw.binom.agentik.desktop.session.LiveTurn -import pw.binom.agentik.journal.Content +import pw.binom.agentik.content.Content import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.MessageRecord import kotlin.time.Instant diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/persistence/ConversationMetaRepository.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/persistence/ConversationMetaRepository.kt index 5460304..0486b7c 100644 --- a/src/jvmMain/kotlin/pw/binom/agentik/desktop/persistence/ConversationMetaRepository.kt +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/persistence/ConversationMetaRepository.kt @@ -20,7 +20,7 @@ import kotlin.time.Instant * `journal.count(convId, after = lastSeen)` и здесь не лежит. * - **`preview`** — последний фрагмент текста хода, денормализованный * в строку. Сервер такого поля не отдаёт (STORAGE.md §6.1), клиент - * поддерживает сам на `OnlineEvent.AppendText` / `Event.End`. + * поддерживает сам на `OnlineEvent.AppendText` / `Event.AssistantMessage`. * * **Почему НЕ наследуется от `KsqliteJournalStore`:** разные таблицы * (`conversation_meta` + `chat_group` против `message`) и разные контракты diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/AgentSyncEngine.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/AgentSyncEngine.kt index 5518461..e617f46 100644 --- a/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/AgentSyncEngine.kt +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/AgentSyncEngine.kt @@ -67,7 +67,7 @@ internal class AgentSyncEngine( outbox.conversationEvents(after = null, conversationId = null).collect { ev -> if (ev.conversationId in openConversationIds) return@collect when (ev.event) { - is Event.End, is Event.Interrupted -> { + is Event.AssistantMessage, is Event.Interrupted, is Event.Error -> { runCatching { syncConversation(ev.conversationId) } onChanged() } diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/ChatSession.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/ChatSession.kt index f27c9c0..f27af89 100644 --- a/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/ChatSession.kt +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/ChatSession.kt @@ -18,7 +18,7 @@ import pw.binom.agentik.journal.MutableJournalStore import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.OnlineOutbox import pw.binom.agentik.outbox.OutboxStore -import pw.binom.agentik.proto.Content +import pw.binom.agentik.content.Content import pw.binom.agentik.proto.Conversation import kotlin.time.Clock import kotlin.time.Instant @@ -33,8 +33,8 @@ import kotlin.time.Instant * Watermark — `conversation_meta.synced_at`, чтобы не вставлять дубли * (`message.id` — PRIMARY KEY, повторный INSERT бросает). * - **Live-ход** — два SSE-потока: durable - * `OutboxStore.conversationEvents` (`Working → … → End`) и онлайн - * `OnlineOutbox.onlineEvents` (стриминг ответа). Оба копятся в + * `OutboxStore.conversationEvents` (`UserMessage → … → AssistantMessage`) и + * онлайн `OnlineOutbox.onlineEvents` (Working/End + стриминг ответа). Оба копятся в * [LiveTurn] и рисуются поверх истории, пока ход не закрыт. * - **Наши поля** — [ConversationMetaRepository]: `lastSeen` (бейдж), * `preview` (строка списка). @@ -42,7 +42,7 @@ import kotlin.time.Instant * ## Почему подписка на SSE берётся `after = synced_at` * * `synced_at` — `createdAt` последнего сообщения, уже лежащего в кэше. - * У завершённого хода все события (`Working`/`AppendText`/`End`) имеют + * У завершённого хода все события (`UserMessage`/`AppendText`/`AssistantMessage`) имеют * `date`, не больший `createdAt` итогового сообщения, — значит, они * отфильтруются и повторно не проиграются. А у **идущего** хода события * свежее — они и попадут в поток. Так «догоняем» уже начатый ход, не @@ -51,7 +51,7 @@ import kotlin.time.Instant * * ## Поток событий vs `sync()` на `End` * - * На `End`/`Interrupted` ход закрыт: подтягиваем авторитетную историю + * На `AssistantMessage`/`Interrupted`/`Error` ход закрыт: подтягиваем авторитетную историю * (`sync()` — инкрементальный бэкфилл + перечитка кэша) и гасим live-блоки, * чтобы не было дублирования «live + history». */ @@ -173,8 +173,10 @@ class ChatSession( .map { it.event } .collect { event -> liveTurn.apply(event) - if (event is Event.End || event is Event.Interrupted) { - // Ход закрыт: авторитетная история важнее live-блоков. + // Ход закрыт durable-событием: успех — готовое + // AssistantMessage, прерывание — Interrupted, провал — + // Error. Авторитетная история важнее live-блоков. + if (event is Event.AssistantMessage || event is Event.Interrupted || event is Event.Error) { runCatching { sync() } liveTurn.reset() } diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/LiveTurn.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/LiveTurn.kt index efcd31a..7c6152f 100644 --- a/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/LiveTurn.kt +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/session/LiveTurn.kt @@ -11,7 +11,7 @@ enum class TurnPhase { /** Хода нет — стор чист. */ IDLE, - /** `send()` ушёл, ответа ещё нет (`Event.Working`). Спиннер «агент думает». */ + /** `send()` ушёл, ответа ещё нет (`OnlineEvent.Working`). Спиннер «агент думает». */ WORKING, /** Идут рассуждения (`StartReasoning` + `AppendText` в reasoning-блок). Live-канал. */ @@ -20,7 +20,7 @@ enum class TurnPhase { /** Стримится ответ (`StartResponse` + `AppendText`/`AppendImage`/tool'ы). Live-канал. */ RESPONDING, - /** Ход закрыт нормально (`End`). Live-блоки пора заменить историей из кэша. */ + /** Ход закрыт нормально (`OnlineEvent.End` / durable `Event.AssistantMessage`). Live-блоки пора заменить историей из кэша. */ DONE, /** Ход прерван пользователем (`Interrupted`). Частичный ответ НЕ сохранён. */ @@ -133,7 +133,7 @@ class LiveTurn { /** * Начинает новый ход: чистит блоки и переводит в [TurnPhase.WORKING]. - * Вызывать перед `send()` (или на `Event.Working`) — иначе блоки + * Вызывать перед `send()` (или на `OnlineEvent.Working`) — иначе блоки * предыдущего хода смешаются с новыми. */ fun start() { @@ -159,9 +159,17 @@ class LiveTurn { */ fun apply(event: Event) { when (event) { - is Event.Working -> { - // Синхронный маркер старта хода: новый ход — с чистого листа. - start() + is Event.UserMessage -> { + // Durable-маркер старта хода. Если онлайн-`Working` уже + // открыл ход, повторно не чистим блоки (защита от гонки + // двух SSE-потоков). + if (!phase.isStreaming) start() + } + + is Event.AssistantMessage -> { + // Durable-терминатор успешного хода: целый ответ уже в журнале. + phase = TurnPhase.DONE + fire() } is Event.ToolCall -> { @@ -203,11 +211,6 @@ class LiveTurn { fire() } - is Event.End -> { - phase = TurnPhase.DONE - fire() - } - is Event.Interrupted -> { phase = TurnPhase.INTERRUPTED fire() @@ -228,10 +231,20 @@ class LiveTurn { * Применяет одно **онлайн** событие ([OnlineEvent]) — live-стриминг * ответа. Онлайн-события нигде не сохраняются: при обрыве соединения * потерянный фрагмент невосстановим (целый результат придёт - * durable-[Event.End] и/или ляжет в журнал). + * durable-[Event.AssistantMessage] и/или ляжет в журнал). */ fun applyOnline(event: OnlineEvent) { when (event) { + is OnlineEvent.Working -> { + // Live-маркер старта хода (эмитится синхронно из send()). + start() + } + + is OnlineEvent.End -> { + phase = TurnPhase.DONE + fire() + } + is OnlineEvent.StartReasoning -> { phase = TurnPhase.REASONING _blocks.add(LiveBlock.Reasoning(nextKey("reasoning"))) diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/model/MappersTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/model/MappersTest.kt index 42f93ae..584e510 100644 --- a/src/jvmTest/kotlin/pw/binom/agentik/desktop/model/MappersTest.kt +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/model/MappersTest.kt @@ -1,6 +1,6 @@ package pw.binom.agentik.desktop.model -import pw.binom.agentik.journal.Content +import pw.binom.agentik.content.Content import pw.binom.agentik.journal.MessageRecord import kotlin.test.Test import kotlin.test.assertEquals diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/net/ConnectionCheckerTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/net/ConnectionCheckerTest.kt index 34ceebc..b3ff077 100644 --- a/src/jvmTest/kotlin/pw/binom/agentik/desktop/net/ConnectionCheckerTest.kt +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/net/ConnectionCheckerTest.kt @@ -12,10 +12,10 @@ import pw.binom.agentik.outbox.inmemory.InMemoryOnlineOutbox import pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.AgentInfo -import pw.binom.agentik.proto.Content +import pw.binom.agentik.content.Content import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Message -import pw.binom.agentik.proto.MessageContext +import pw.binom.agentik.content.MessageContext import pw.binom.agentik.server.agentikAgent import kotlin.test.AfterTest import kotlin.test.Test diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentConnectionE2ETest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentConnectionE2ETest.kt index 03aafe0..08bbe5f 100644 --- a/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentConnectionE2ETest.kt +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentConnectionE2ETest.kt @@ -26,10 +26,10 @@ import pw.binom.agentik.outbox.inmemory.InMemoryOnlineOutbox import pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.AgentInfo -import pw.binom.agentik.proto.Content +import pw.binom.agentik.content.Content import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Message -import pw.binom.agentik.proto.MessageContext +import pw.binom.agentik.content.MessageContext import pw.binom.agentik.server.agentikAgent import kotlin.test.AfterTest import kotlin.test.Test @@ -283,10 +283,15 @@ private class EchoConversation( override suspend fun send(content: List, context: MessageContext?) { val now = Clock.System.now() + val userId = nextMsgId(id, "user") journal.append( - MessageRecord.UserMessage(nextMsgId(id, "user"), id, content.toJournal(), now, context = null), + MessageRecord.UserMessage(userId, id, content.toJournal(), now, context = null), ) - emit(now, Event.Working(now)) + emit( + now, + Event.UserMessage(date = now, id = userId, content = content.toJournal()), + ) + onlineOutbox.tryAppendOnline(id, OnlineEvent.Working(now)) if (interruptible) { // Имитация долгого LLM-цикла: крутимся, пока не придёт interrupt(). @@ -300,15 +305,17 @@ private class EchoConversation( onlineOutbox.tryAppendOnline(id, OnlineEvent.AppendText(now, reply)) val endAt = Clock.System.now() + val assistantId = nextMsgId(id, "assistant") journal.append( MessageRecord.AssistantMessage( - nextMsgId(id, "assistant"), + assistantId, id, - listOf(pw.binom.agentik.journal.Content.Text(reply)), + listOf(pw.binom.agentik.content.Content.Text(reply)), endAt, ), ) - emit(endAt, Event.End(endAt)) + onlineOutbox.tryAppendOnline(id, OnlineEvent.End(endAt)) + emit(endAt, Event.AssistantMessage(date = endAt, id = assistantId, content = listOf(Content.Text(reply)))) updated = endAt store.touch(id, endAt) @@ -323,9 +330,9 @@ private class EchoConversation( } /** `:proto.Content` → `:journal.Content` (в проде это делает storage-impl). */ -private fun List.toJournal(): List = map { c -> +private fun List.toJournal(): List = map { c -> when (c) { - is Content.Text -> pw.binom.agentik.journal.Content.Text(c.body) - is Content.Image -> pw.binom.agentik.journal.Content.Image(c.data, c.mime) + is Content.Text -> pw.binom.agentik.content.Content.Text(c.body) + is Content.Image -> pw.binom.agentik.content.Content.Image(c.data, c.mime) } } diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentSyncEngineTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentSyncEngineTest.kt index 2ec98ec..ff1991f 100644 --- a/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentSyncEngineTest.kt +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/AgentSyncEngineTest.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import org.junit.jupiter.api.Test import pw.binom.agentik.desktop.persistence.ConversationMetaRepository -import pw.binom.agentik.journal.Content as JournalContent +import pw.binom.agentik.content.Content as JournalContent import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.MessageRecord @@ -160,7 +160,9 @@ class AgentSyncEngineTest { f.outbox.awaitSubscriber() f.remote.records = listOf(user("u1", 1, "привет"), assistant("a1", 2, "пока")) - f.outbox.emit(Event.End(date = at(3))) + f.outbox.emit( + Event.AssistantMessage(date = at(3), id = "a1", content = listOf(JournalContent.Text("пока"))) + ) await { f.cache.count(convId) == 2L } await { f.meta.meta(convId)?.preview == "пока" } @@ -178,7 +180,9 @@ class AgentSyncEngineTest { f.outbox.awaitSubscriber() f.remote.records = listOf(user("u1", 1, "привет")) - f.outbox.emit(Event.End(date = at(3))) + f.outbox.emit( + Event.AssistantMessage(date = at(3), id = "a1", content = listOf(JournalContent.Text("привет"))) + ) delay(150) assertEquals(0L, f.cache.count(convId)) diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/ChatSessionTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/ChatSessionTest.kt index 29b6e8e..1235302 100644 --- a/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/ChatSessionTest.kt +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/ChatSessionTest.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.junit.jupiter.api.Test import pw.binom.agentik.desktop.model.UiMessage import pw.binom.agentik.desktop.persistence.ConversationMetaRepository -import pw.binom.agentik.journal.Content as JournalContent +import pw.binom.agentik.content.Content as JournalContent import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore @@ -22,10 +22,10 @@ import pw.binom.agentik.outbox.Event import pw.binom.agentik.outbox.OnlineEvent import pw.binom.agentik.outbox.OnlineOutbox import pw.binom.agentik.outbox.OutboxStore -import pw.binom.agentik.proto.Content +import pw.binom.agentik.content.Content import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Message -import pw.binom.agentik.proto.MessageContext +import pw.binom.agentik.content.MessageContext import pw.binom.db.ksqlite.SQLiteConnection import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -163,7 +163,7 @@ class ChatSessionTest { f.outbox.awaitSubscriber() f.online.awaitSubscriber() - f.outbox.emit(Event.Working(at(10))) + f.online.emit(OnlineEvent.Working(at(10))) f.online.emit(OnlineEvent.StartResponse(at(11), OnlineEvent.ResponseType.TEXT)) f.online.emit(OnlineEvent.AppendText(at(12), "Hel")) f.online.emit(OnlineEvent.AppendText(at(13), "lo")) @@ -174,9 +174,11 @@ class ChatSessionTest { assertEquals("Hello", (session.live.value.single() as UiMessage.Assistant) .let { (it.content.single() as pw.binom.agentik.desktop.model.UiContent.Text).body }) - // Сервер зафиксировал ход в журнале и прислал End. + // Сервер зафиксировал ход в журнале и прислал durable AssistantMessage. f.remote.records = listOf(user("u1", 9, "вопрос"), assistant("a1", 14, "Hello")) - f.outbox.emit(Event.End(at(14))) + f.outbox.emit( + Event.AssistantMessage(at(14), id = "a1", content = listOf(JournalContent.Text("Hello"))) + ) await { session.live.value.isEmpty() && session.history.value.size == 2 } assertTrue(session.live.value.isEmpty()) @@ -201,7 +203,9 @@ class ChatSessionTest { // стоял ровно на 5 мс — отсюда отступ назад на 1 мс + отсечение // известных id вместо опоры на время. f.remote.records = listOf(user("u1", 5, "вопрос"), assistant("a1", 5, "ответ")) - f.outbox.emit(Event.End(at(5))) + f.outbox.emit( + Event.AssistantMessage(at(5), id = "a1", content = listOf(JournalContent.Text("ответ"))) + ) await { session.history.value.size == 2 } // Порядок при равных timestamp'ах задаёт id (контракт SQL @@ -305,6 +309,8 @@ private class FakeOutboxStore : OutboxStore { private class FakeOnlineOutbox : OnlineOutbox { private val shared = MutableSharedFlow(replay = 256, extraBufferCapacity = 256) + override fun onlineEvents(): Flow = shared.asSharedFlow() + override fun onlineEvents(conversationId: String): Flow = shared.asSharedFlow() override fun close() {} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/LiveTurnTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/LiveTurnTest.kt index 9ad3ef8..db4aa51 100644 --- a/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/LiveTurnTest.kt +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/session/LiveTurnTest.kt @@ -28,14 +28,14 @@ class LiveTurnTest { fun `full happy path accumulates reasoning and answer`() { val turn = LiveTurn() turn.feed( - Event.Working(at()), + OnlineEvent.Working(at()), OnlineEvent.StartReasoning(at()), OnlineEvent.AppendText(at(), "сначала "), OnlineEvent.AppendText(at(), "подумаем"), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT), OnlineEvent.AppendText(at(), "Привет, "), OnlineEvent.AppendText(at(), "мир!"), - Event.End(at()), + OnlineEvent.End(at()), ) assertEquals(TurnPhase.DONE, turn.phase) @@ -52,11 +52,11 @@ class LiveTurnTest { fun `tool call and result are stitched into one block`() { val turn = LiveTurn() turn.feed( - Event.Working(at()), + 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"), - Event.End(at()), + OnlineEvent.End(at()), ) val tools = turn.blocks.filterIsInstance() @@ -72,7 +72,7 @@ class LiveTurnTest { fun `orphan tool result creates a block`() { val turn = LiveTurn() turn.feed( - Event.Working(at()), + OnlineEvent.Working(at()), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT), Event.ToolResult(at(), toolCallId = "missing", toolName = null, result = "ok"), ) @@ -87,7 +87,7 @@ class LiveTurnTest { fun `tool failed marks the block`() { val turn = LiveTurn() turn.feed( - Event.Working(at()), + OnlineEvent.Working(at()), Event.ToolCall(at(), id = "t9", title = null, toolName = "bash", toolArgs = "{}"), Event.ToolFailed(at(), toolCallId = "t9", toolName = "bash", message = "boom", durationMs = 5), ) @@ -100,7 +100,7 @@ class LiveTurnTest { fun `error appends failure block and fails the turn`() { val turn = LiveTurn() turn.feed( - Event.Working(at()), + OnlineEvent.Working(at()), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT), OnlineEvent.AppendText(at(), "partial"), Event.Error(at(), message = "llm down", code = "E500"), @@ -118,7 +118,7 @@ class LiveTurnTest { fun `interrupted turn is terminal and keeps partial text`() { val turn = LiveTurn() turn.feed( - Event.Working(at()), + OnlineEvent.Working(at()), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT), OnlineEvent.AppendText(at(), "не до конца"), Event.Interrupted(at()), @@ -133,7 +133,7 @@ class LiveTurnTest { fun `text after tool call starts a new answer block`() { val turn = LiveTurn() turn.feed( - Event.Working(at()), + OnlineEvent.Working(at()), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT), OnlineEvent.AppendText(at(), "до"), Event.ToolCall(at(), id = "t1", title = null, toolName = "grep", toolArgs = "{}"), @@ -152,10 +152,10 @@ class LiveTurnTest { val bytes = byteArrayOf(1, 2, 3) val turn = LiveTurn() turn.feed( - Event.Working(at()), + OnlineEvent.Working(at()), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.IMAGE), OnlineEvent.AppendImage(at(), body = bytes, mime = "image/png"), - Event.End(at()), + OnlineEvent.End(at()), ) assertTrue(turn.blocks.filterIsInstance().isEmpty()) @@ -168,12 +168,12 @@ class LiveTurnTest { fun `working resets the previous turn`() { val turn = LiveTurn() turn.feed( - Event.Working(at()), + OnlineEvent.Working(at()), OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT), OnlineEvent.AppendText(at(), "старое"), - Event.End(at()), + OnlineEvent.End(at()), ) - turn.feed(Event.Working(at()), OnlineEvent.AppendText(at(), "новое")) + turn.feed(OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "новое")) assertEquals(1, turn.blocks.size) assertEquals("новое", turn.answerText()) @@ -182,7 +182,7 @@ class LiveTurnTest { @Test fun `append without explicit start response still streams as answer`() { val turn = LiveTurn() - turn.feed(Event.Working(at()), OnlineEvent.AppendText(at(), "без старта")) + turn.feed(OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "без старта")) assertEquals(TurnPhase.RESPONDING, turn.phase) assertEquals("без старта", turn.answerText()) @@ -193,7 +193,7 @@ class LiveTurnTest { val turn = LiveTurn() var count = 0 turn.onChange = { count++ } - turn.feed(Event.Working(at()), OnlineEvent.AppendText(at(), "x")) + turn.feed(OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "x")) assertEquals(2, count) } @@ -202,13 +202,13 @@ class LiveTurnTest { fun `preview text collapses whitespace and truncates`() { val turn = LiveTurn() turn.feed( - Event.Working(at()), + OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "Первая строка\n\nвторая строка "), ) assertEquals("Первая строка вторая строка", turn.previewText()) val long = LiveTurn() - long.feed(Event.Working(at()), OnlineEvent.AppendText(at(), "слово ".repeat(60))) + long.feed(OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "слово ".repeat(60))) val preview = long.previewText(maxLength = 30) assertTrue(preview.length <= 31) assertTrue(preview.endsWith("…"))