Изменение выносит типы контента в :content-api и разделяет потоки событий.
This commit is contained in:
@@ -3,7 +3,7 @@ package pw.binom.agentik.desktop.model
|
|||||||
import pw.binom.agentik.desktop.persistence.MetaRow
|
import pw.binom.agentik.desktop.persistence.MetaRow
|
||||||
import pw.binom.agentik.desktop.session.LiveBlock
|
import pw.binom.agentik.desktop.session.LiveBlock
|
||||||
import pw.binom.agentik.desktop.session.LiveTurn
|
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.ConversationRecord
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ import kotlin.time.Instant
|
|||||||
* `journal.count(convId, after = lastSeen)` и здесь не лежит.
|
* `journal.count(convId, after = lastSeen)` и здесь не лежит.
|
||||||
* - **`preview`** — последний фрагмент текста хода, денормализованный
|
* - **`preview`** — последний фрагмент текста хода, денормализованный
|
||||||
* в строку. Сервер такого поля не отдаёт (STORAGE.md §6.1), клиент
|
* в строку. Сервер такого поля не отдаёт (STORAGE.md §6.1), клиент
|
||||||
* поддерживает сам на `OnlineEvent.AppendText` / `Event.End`.
|
* поддерживает сам на `OnlineEvent.AppendText` / `Event.AssistantMessage`.
|
||||||
*
|
*
|
||||||
* **Почему НЕ наследуется от `KsqliteJournalStore`:** разные таблицы
|
* **Почему НЕ наследуется от `KsqliteJournalStore`:** разные таблицы
|
||||||
* (`conversation_meta` + `chat_group` против `message`) и разные контракты
|
* (`conversation_meta` + `chat_group` против `message`) и разные контракты
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ internal class AgentSyncEngine(
|
|||||||
outbox.conversationEvents(after = null, conversationId = null).collect { ev ->
|
outbox.conversationEvents(after = null, conversationId = null).collect { ev ->
|
||||||
if (ev.conversationId in openConversationIds) return@collect
|
if (ev.conversationId in openConversationIds) return@collect
|
||||||
when (ev.event) {
|
when (ev.event) {
|
||||||
is Event.End, is Event.Interrupted -> {
|
is Event.AssistantMessage, is Event.Interrupted, is Event.Error -> {
|
||||||
runCatching { syncConversation(ev.conversationId) }
|
runCatching { syncConversation(ev.conversationId) }
|
||||||
onChanged()
|
onChanged()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import pw.binom.agentik.journal.MutableJournalStore
|
|||||||
import pw.binom.agentik.outbox.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
import pw.binom.agentik.outbox.OnlineOutbox
|
import pw.binom.agentik.outbox.OnlineOutbox
|
||||||
import pw.binom.agentik.outbox.OutboxStore
|
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.Conversation
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -33,8 +33,8 @@ import kotlin.time.Instant
|
|||||||
* Watermark — `conversation_meta.synced_at`, чтобы не вставлять дубли
|
* Watermark — `conversation_meta.synced_at`, чтобы не вставлять дубли
|
||||||
* (`message.id` — PRIMARY KEY, повторный INSERT бросает).
|
* (`message.id` — PRIMARY KEY, повторный INSERT бросает).
|
||||||
* - **Live-ход** — два SSE-потока: durable
|
* - **Live-ход** — два SSE-потока: durable
|
||||||
* `OutboxStore.conversationEvents` (`Working → … → End`) и онлайн
|
* `OutboxStore.conversationEvents` (`UserMessage → … → AssistantMessage`) и
|
||||||
* `OnlineOutbox.onlineEvents` (стриминг ответа). Оба копятся в
|
* онлайн `OnlineOutbox.onlineEvents` (Working/End + стриминг ответа). Оба копятся в
|
||||||
* [LiveTurn] и рисуются поверх истории, пока ход не закрыт.
|
* [LiveTurn] и рисуются поверх истории, пока ход не закрыт.
|
||||||
* - **Наши поля** — [ConversationMetaRepository]: `lastSeen` (бейдж),
|
* - **Наши поля** — [ConversationMetaRepository]: `lastSeen` (бейдж),
|
||||||
* `preview` (строка списка).
|
* `preview` (строка списка).
|
||||||
@@ -42,7 +42,7 @@ import kotlin.time.Instant
|
|||||||
* ## Почему подписка на SSE берётся `after = synced_at`
|
* ## Почему подписка на SSE берётся `after = synced_at`
|
||||||
*
|
*
|
||||||
* `synced_at` — `createdAt` последнего сообщения, уже лежащего в кэше.
|
* `synced_at` — `createdAt` последнего сообщения, уже лежащего в кэше.
|
||||||
* У завершённого хода все события (`Working`/`AppendText`/`End`) имеют
|
* У завершённого хода все события (`UserMessage`/`AppendText`/`AssistantMessage`) имеют
|
||||||
* `date`, не больший `createdAt` итогового сообщения, — значит, они
|
* `date`, не больший `createdAt` итогового сообщения, — значит, они
|
||||||
* отфильтруются и повторно не проиграются. А у **идущего** хода события
|
* отфильтруются и повторно не проиграются. А у **идущего** хода события
|
||||||
* свежее — они и попадут в поток. Так «догоняем» уже начатый ход, не
|
* свежее — они и попадут в поток. Так «догоняем» уже начатый ход, не
|
||||||
@@ -51,7 +51,7 @@ import kotlin.time.Instant
|
|||||||
*
|
*
|
||||||
* ## Поток событий vs `sync()` на `End`
|
* ## Поток событий vs `sync()` на `End`
|
||||||
*
|
*
|
||||||
* На `End`/`Interrupted` ход закрыт: подтягиваем авторитетную историю
|
* На `AssistantMessage`/`Interrupted`/`Error` ход закрыт: подтягиваем авторитетную историю
|
||||||
* (`sync()` — инкрементальный бэкфилл + перечитка кэша) и гасим live-блоки,
|
* (`sync()` — инкрементальный бэкфилл + перечитка кэша) и гасим live-блоки,
|
||||||
* чтобы не было дублирования «live + history».
|
* чтобы не было дублирования «live + history».
|
||||||
*/
|
*/
|
||||||
@@ -173,8 +173,10 @@ class ChatSession(
|
|||||||
.map { it.event }
|
.map { it.event }
|
||||||
.collect { event ->
|
.collect { event ->
|
||||||
liveTurn.apply(event)
|
liveTurn.apply(event)
|
||||||
if (event is Event.End || event is Event.Interrupted) {
|
// Ход закрыт durable-событием: успех — готовое
|
||||||
// Ход закрыт: авторитетная история важнее live-блоков.
|
// AssistantMessage, прерывание — Interrupted, провал —
|
||||||
|
// Error. Авторитетная история важнее live-блоков.
|
||||||
|
if (event is Event.AssistantMessage || event is Event.Interrupted || event is Event.Error) {
|
||||||
runCatching { sync() }
|
runCatching { sync() }
|
||||||
liveTurn.reset()
|
liveTurn.reset()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ enum class TurnPhase {
|
|||||||
/** Хода нет — стор чист. */
|
/** Хода нет — стор чист. */
|
||||||
IDLE,
|
IDLE,
|
||||||
|
|
||||||
/** `send()` ушёл, ответа ещё нет (`Event.Working`). Спиннер «агент думает». */
|
/** `send()` ушёл, ответа ещё нет (`OnlineEvent.Working`). Спиннер «агент думает». */
|
||||||
WORKING,
|
WORKING,
|
||||||
|
|
||||||
/** Идут рассуждения (`StartReasoning` + `AppendText` в reasoning-блок). Live-канал. */
|
/** Идут рассуждения (`StartReasoning` + `AppendText` в reasoning-блок). Live-канал. */
|
||||||
@@ -20,7 +20,7 @@ enum class TurnPhase {
|
|||||||
/** Стримится ответ (`StartResponse` + `AppendText`/`AppendImage`/tool'ы). Live-канал. */
|
/** Стримится ответ (`StartResponse` + `AppendText`/`AppendImage`/tool'ы). Live-канал. */
|
||||||
RESPONDING,
|
RESPONDING,
|
||||||
|
|
||||||
/** Ход закрыт нормально (`End`). Live-блоки пора заменить историей из кэша. */
|
/** Ход закрыт нормально (`OnlineEvent.End` / durable `Event.AssistantMessage`). Live-блоки пора заменить историей из кэша. */
|
||||||
DONE,
|
DONE,
|
||||||
|
|
||||||
/** Ход прерван пользователем (`Interrupted`). Частичный ответ НЕ сохранён. */
|
/** Ход прерван пользователем (`Interrupted`). Частичный ответ НЕ сохранён. */
|
||||||
@@ -133,7 +133,7 @@ class LiveTurn {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Начинает новый ход: чистит блоки и переводит в [TurnPhase.WORKING].
|
* Начинает новый ход: чистит блоки и переводит в [TurnPhase.WORKING].
|
||||||
* Вызывать перед `send()` (или на `Event.Working`) — иначе блоки
|
* Вызывать перед `send()` (или на `OnlineEvent.Working`) — иначе блоки
|
||||||
* предыдущего хода смешаются с новыми.
|
* предыдущего хода смешаются с новыми.
|
||||||
*/
|
*/
|
||||||
fun start() {
|
fun start() {
|
||||||
@@ -159,9 +159,17 @@ class LiveTurn {
|
|||||||
*/
|
*/
|
||||||
fun apply(event: Event) {
|
fun apply(event: Event) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is Event.Working -> {
|
is Event.UserMessage -> {
|
||||||
// Синхронный маркер старта хода: новый ход — с чистого листа.
|
// Durable-маркер старта хода. Если онлайн-`Working` уже
|
||||||
start()
|
// открыл ход, повторно не чистим блоки (защита от гонки
|
||||||
|
// двух SSE-потоков).
|
||||||
|
if (!phase.isStreaming) start()
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.AssistantMessage -> {
|
||||||
|
// Durable-терминатор успешного хода: целый ответ уже в журнале.
|
||||||
|
phase = TurnPhase.DONE
|
||||||
|
fire()
|
||||||
}
|
}
|
||||||
|
|
||||||
is Event.ToolCall -> {
|
is Event.ToolCall -> {
|
||||||
@@ -203,11 +211,6 @@ class LiveTurn {
|
|||||||
fire()
|
fire()
|
||||||
}
|
}
|
||||||
|
|
||||||
is Event.End -> {
|
|
||||||
phase = TurnPhase.DONE
|
|
||||||
fire()
|
|
||||||
}
|
|
||||||
|
|
||||||
is Event.Interrupted -> {
|
is Event.Interrupted -> {
|
||||||
phase = TurnPhase.INTERRUPTED
|
phase = TurnPhase.INTERRUPTED
|
||||||
fire()
|
fire()
|
||||||
@@ -228,10 +231,20 @@ class LiveTurn {
|
|||||||
* Применяет одно **онлайн** событие ([OnlineEvent]) — live-стриминг
|
* Применяет одно **онлайн** событие ([OnlineEvent]) — live-стриминг
|
||||||
* ответа. Онлайн-события нигде не сохраняются: при обрыве соединения
|
* ответа. Онлайн-события нигде не сохраняются: при обрыве соединения
|
||||||
* потерянный фрагмент невосстановим (целый результат придёт
|
* потерянный фрагмент невосстановим (целый результат придёт
|
||||||
* durable-[Event.End] и/или ляжет в журнал).
|
* durable-[Event.AssistantMessage] и/или ляжет в журнал).
|
||||||
*/
|
*/
|
||||||
fun applyOnline(event: OnlineEvent) {
|
fun applyOnline(event: OnlineEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
is OnlineEvent.Working -> {
|
||||||
|
// Live-маркер старта хода (эмитится синхронно из send()).
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
is OnlineEvent.End -> {
|
||||||
|
phase = TurnPhase.DONE
|
||||||
|
fire()
|
||||||
|
}
|
||||||
|
|
||||||
is OnlineEvent.StartReasoning -> {
|
is OnlineEvent.StartReasoning -> {
|
||||||
phase = TurnPhase.REASONING
|
phase = TurnPhase.REASONING
|
||||||
_blocks.add(LiveBlock.Reasoning(nextKey("reasoning")))
|
_blocks.add(LiveBlock.Reasoning(nextKey("reasoning")))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package pw.binom.agentik.desktop.model
|
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 pw.binom.agentik.journal.MessageRecord
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import pw.binom.agentik.outbox.inmemory.InMemoryOnlineOutbox
|
|||||||
import pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore
|
import pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.AgentInfo
|
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.Conversation
|
||||||
import pw.binom.agentik.proto.Message
|
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 pw.binom.agentik.server.agentikAgent
|
||||||
import kotlin.test.AfterTest
|
import kotlin.test.AfterTest
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ import pw.binom.agentik.outbox.inmemory.InMemoryOnlineOutbox
|
|||||||
import pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore
|
import pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.AgentInfo
|
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.Conversation
|
||||||
import pw.binom.agentik.proto.Message
|
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 pw.binom.agentik.server.agentikAgent
|
||||||
import kotlin.test.AfterTest
|
import kotlin.test.AfterTest
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
@@ -283,10 +283,15 @@ private class EchoConversation(
|
|||||||
|
|
||||||
override suspend fun send(content: List<Content>, context: MessageContext?) {
|
override suspend fun send(content: List<Content>, context: MessageContext?) {
|
||||||
val now = Clock.System.now()
|
val now = Clock.System.now()
|
||||||
|
val userId = nextMsgId(id, "user")
|
||||||
journal.append(
|
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) {
|
if (interruptible) {
|
||||||
// Имитация долгого LLM-цикла: крутимся, пока не придёт interrupt().
|
// Имитация долгого LLM-цикла: крутимся, пока не придёт interrupt().
|
||||||
@@ -300,15 +305,17 @@ private class EchoConversation(
|
|||||||
onlineOutbox.tryAppendOnline(id, OnlineEvent.AppendText(now, reply))
|
onlineOutbox.tryAppendOnline(id, OnlineEvent.AppendText(now, reply))
|
||||||
|
|
||||||
val endAt = Clock.System.now()
|
val endAt = Clock.System.now()
|
||||||
|
val assistantId = nextMsgId(id, "assistant")
|
||||||
journal.append(
|
journal.append(
|
||||||
MessageRecord.AssistantMessage(
|
MessageRecord.AssistantMessage(
|
||||||
nextMsgId(id, "assistant"),
|
assistantId,
|
||||||
id,
|
id,
|
||||||
listOf(pw.binom.agentik.journal.Content.Text(reply)),
|
listOf(pw.binom.agentik.content.Content.Text(reply)),
|
||||||
endAt,
|
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
|
updated = endAt
|
||||||
store.touch(id, endAt)
|
store.touch(id, endAt)
|
||||||
@@ -323,9 +330,9 @@ private class EchoConversation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `:proto.Content` → `:journal.Content` (в проде это делает storage-impl). */
|
/** `:proto.Content` → `:journal.Content` (в проде это делает storage-impl). */
|
||||||
private fun List<Content>.toJournal(): List<pw.binom.agentik.journal.Content> = map { c ->
|
private fun List<Content>.toJournal(): List<pw.binom.agentik.content.Content> = map { c ->
|
||||||
when (c) {
|
when (c) {
|
||||||
is Content.Text -> pw.binom.agentik.journal.Content.Text(c.body)
|
is Content.Text -> pw.binom.agentik.content.Content.Text(c.body)
|
||||||
is Content.Image -> pw.binom.agentik.journal.Content.Image(c.data, c.mime)
|
is Content.Image -> pw.binom.agentik.content.Content.Image(c.data, c.mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import kotlinx.coroutines.runBlocking
|
|||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import pw.binom.agentik.desktop.persistence.ConversationMetaRepository
|
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.ConversationRecord
|
||||||
import pw.binom.agentik.journal.JournalStore
|
import pw.binom.agentik.journal.JournalStore
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
@@ -160,7 +160,9 @@ class AgentSyncEngineTest {
|
|||||||
f.outbox.awaitSubscriber()
|
f.outbox.awaitSubscriber()
|
||||||
|
|
||||||
f.remote.records = listOf(user("u1", 1, "привет"), assistant("a1", 2, "пока"))
|
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>(JournalContent.Text("пока")))
|
||||||
|
)
|
||||||
|
|
||||||
await { f.cache.count(convId) == 2L }
|
await { f.cache.count(convId) == 2L }
|
||||||
await { f.meta.meta(convId)?.preview == "пока" }
|
await { f.meta.meta(convId)?.preview == "пока" }
|
||||||
@@ -178,7 +180,9 @@ class AgentSyncEngineTest {
|
|||||||
f.outbox.awaitSubscriber()
|
f.outbox.awaitSubscriber()
|
||||||
|
|
||||||
f.remote.records = listOf(user("u1", 1, "привет"))
|
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>(JournalContent.Text("привет")))
|
||||||
|
)
|
||||||
delay(150)
|
delay(150)
|
||||||
|
|
||||||
assertEquals(0L, f.cache.count(convId))
|
assertEquals(0L, f.cache.count(convId))
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import kotlinx.coroutines.withTimeoutOrNull
|
|||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import pw.binom.agentik.desktop.model.UiMessage
|
import pw.binom.agentik.desktop.model.UiMessage
|
||||||
import pw.binom.agentik.desktop.persistence.ConversationMetaRepository
|
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.JournalStore
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
|
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.OnlineEvent
|
||||||
import pw.binom.agentik.outbox.OnlineOutbox
|
import pw.binom.agentik.outbox.OnlineOutbox
|
||||||
import pw.binom.agentik.outbox.OutboxStore
|
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.Conversation
|
||||||
import pw.binom.agentik.proto.Message
|
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 pw.binom.db.ksqlite.SQLiteConnection
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
@@ -163,7 +163,7 @@ class ChatSessionTest {
|
|||||||
f.outbox.awaitSubscriber()
|
f.outbox.awaitSubscriber()
|
||||||
f.online.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.StartResponse(at(11), OnlineEvent.ResponseType.TEXT))
|
||||||
f.online.emit(OnlineEvent.AppendText(at(12), "Hel"))
|
f.online.emit(OnlineEvent.AppendText(at(12), "Hel"))
|
||||||
f.online.emit(OnlineEvent.AppendText(at(13), "lo"))
|
f.online.emit(OnlineEvent.AppendText(at(13), "lo"))
|
||||||
@@ -174,9 +174,11 @@ class ChatSessionTest {
|
|||||||
assertEquals("Hello", (session.live.value.single() as UiMessage.Assistant)
|
assertEquals("Hello", (session.live.value.single() as UiMessage.Assistant)
|
||||||
.let { (it.content.single() as pw.binom.agentik.desktop.model.UiContent.Text).body })
|
.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.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>(JournalContent.Text("Hello")))
|
||||||
|
)
|
||||||
await { session.live.value.isEmpty() && session.history.value.size == 2 }
|
await { session.live.value.isEmpty() && session.history.value.size == 2 }
|
||||||
|
|
||||||
assertTrue(session.live.value.isEmpty())
|
assertTrue(session.live.value.isEmpty())
|
||||||
@@ -201,7 +203,9 @@ class ChatSessionTest {
|
|||||||
// стоял ровно на 5 мс — отсюда отступ назад на 1 мс + отсечение
|
// стоял ровно на 5 мс — отсюда отступ назад на 1 мс + отсечение
|
||||||
// известных id вместо опоры на время.
|
// известных id вместо опоры на время.
|
||||||
f.remote.records = listOf(user("u1", 5, "вопрос"), assistant("a1", 5, "ответ"))
|
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>(JournalContent.Text("ответ")))
|
||||||
|
)
|
||||||
|
|
||||||
await { session.history.value.size == 2 }
|
await { session.history.value.size == 2 }
|
||||||
// Порядок при равных timestamp'ах задаёт id (контракт SQL
|
// Порядок при равных timestamp'ах задаёт id (контракт SQL
|
||||||
@@ -305,6 +309,8 @@ private class FakeOutboxStore : OutboxStore {
|
|||||||
private class FakeOnlineOutbox : OnlineOutbox {
|
private class FakeOnlineOutbox : OnlineOutbox {
|
||||||
private val shared = MutableSharedFlow<OnlineEvent>(replay = 256, extraBufferCapacity = 256)
|
private val shared = MutableSharedFlow<OnlineEvent>(replay = 256, extraBufferCapacity = 256)
|
||||||
|
|
||||||
|
override fun onlineEvents(): Flow<OnlineEvent> = shared.asSharedFlow()
|
||||||
|
|
||||||
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> = shared.asSharedFlow()
|
override fun onlineEvents(conversationId: String): Flow<OnlineEvent> = shared.asSharedFlow()
|
||||||
|
|
||||||
override fun close() {}
|
override fun close() {}
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ class LiveTurnTest {
|
|||||||
fun `full happy path accumulates reasoning and answer`() {
|
fun `full happy path accumulates reasoning and answer`() {
|
||||||
val turn = LiveTurn()
|
val turn = LiveTurn()
|
||||||
turn.feed(
|
turn.feed(
|
||||||
Event.Working(at()),
|
OnlineEvent.Working(at()),
|
||||||
OnlineEvent.StartReasoning(at()),
|
OnlineEvent.StartReasoning(at()),
|
||||||
OnlineEvent.AppendText(at(), "сначала "),
|
OnlineEvent.AppendText(at(), "сначала "),
|
||||||
OnlineEvent.AppendText(at(), "подумаем"),
|
OnlineEvent.AppendText(at(), "подумаем"),
|
||||||
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
||||||
OnlineEvent.AppendText(at(), "Привет, "),
|
OnlineEvent.AppendText(at(), "Привет, "),
|
||||||
OnlineEvent.AppendText(at(), "мир!"),
|
OnlineEvent.AppendText(at(), "мир!"),
|
||||||
Event.End(at()),
|
OnlineEvent.End(at()),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(TurnPhase.DONE, turn.phase)
|
assertEquals(TurnPhase.DONE, turn.phase)
|
||||||
@@ -52,11 +52,11 @@ class LiveTurnTest {
|
|||||||
fun `tool call and result are stitched into one block`() {
|
fun `tool call and result are stitched into one block`() {
|
||||||
val turn = LiveTurn()
|
val turn = LiveTurn()
|
||||||
turn.feed(
|
turn.feed(
|
||||||
Event.Working(at()),
|
OnlineEvent.Working(at()),
|
||||||
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
||||||
Event.ToolCall(at(), id = "t1", title = "grep", toolName = "grep", toolArgs = "{\"q\":\"x\"}"),
|
Event.ToolCall(at(), id = "t1", title = "grep", toolName = "grep", toolArgs = "{\"q\":\"x\"}"),
|
||||||
Event.ToolResult(at(), toolCallId = "t1", toolName = "grep", result = "found 3"),
|
Event.ToolResult(at(), toolCallId = "t1", toolName = "grep", result = "found 3"),
|
||||||
Event.End(at()),
|
OnlineEvent.End(at()),
|
||||||
)
|
)
|
||||||
|
|
||||||
val tools = turn.blocks.filterIsInstance<LiveBlock.Tool>()
|
val tools = turn.blocks.filterIsInstance<LiveBlock.Tool>()
|
||||||
@@ -72,7 +72,7 @@ class LiveTurnTest {
|
|||||||
fun `orphan tool result creates a block`() {
|
fun `orphan tool result creates a block`() {
|
||||||
val turn = LiveTurn()
|
val turn = LiveTurn()
|
||||||
turn.feed(
|
turn.feed(
|
||||||
Event.Working(at()),
|
OnlineEvent.Working(at()),
|
||||||
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
||||||
Event.ToolResult(at(), toolCallId = "missing", toolName = null, result = "ok"),
|
Event.ToolResult(at(), toolCallId = "missing", toolName = null, result = "ok"),
|
||||||
)
|
)
|
||||||
@@ -87,7 +87,7 @@ class LiveTurnTest {
|
|||||||
fun `tool failed marks the block`() {
|
fun `tool failed marks the block`() {
|
||||||
val turn = LiveTurn()
|
val turn = LiveTurn()
|
||||||
turn.feed(
|
turn.feed(
|
||||||
Event.Working(at()),
|
OnlineEvent.Working(at()),
|
||||||
Event.ToolCall(at(), id = "t9", title = null, toolName = "bash", toolArgs = "{}"),
|
Event.ToolCall(at(), id = "t9", title = null, toolName = "bash", toolArgs = "{}"),
|
||||||
Event.ToolFailed(at(), toolCallId = "t9", toolName = "bash", message = "boom", durationMs = 5),
|
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`() {
|
fun `error appends failure block and fails the turn`() {
|
||||||
val turn = LiveTurn()
|
val turn = LiveTurn()
|
||||||
turn.feed(
|
turn.feed(
|
||||||
Event.Working(at()),
|
OnlineEvent.Working(at()),
|
||||||
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
||||||
OnlineEvent.AppendText(at(), "partial"),
|
OnlineEvent.AppendText(at(), "partial"),
|
||||||
Event.Error(at(), message = "llm down", code = "E500"),
|
Event.Error(at(), message = "llm down", code = "E500"),
|
||||||
@@ -118,7 +118,7 @@ class LiveTurnTest {
|
|||||||
fun `interrupted turn is terminal and keeps partial text`() {
|
fun `interrupted turn is terminal and keeps partial text`() {
|
||||||
val turn = LiveTurn()
|
val turn = LiveTurn()
|
||||||
turn.feed(
|
turn.feed(
|
||||||
Event.Working(at()),
|
OnlineEvent.Working(at()),
|
||||||
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
||||||
OnlineEvent.AppendText(at(), "не до конца"),
|
OnlineEvent.AppendText(at(), "не до конца"),
|
||||||
Event.Interrupted(at()),
|
Event.Interrupted(at()),
|
||||||
@@ -133,7 +133,7 @@ class LiveTurnTest {
|
|||||||
fun `text after tool call starts a new answer block`() {
|
fun `text after tool call starts a new answer block`() {
|
||||||
val turn = LiveTurn()
|
val turn = LiveTurn()
|
||||||
turn.feed(
|
turn.feed(
|
||||||
Event.Working(at()),
|
OnlineEvent.Working(at()),
|
||||||
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
||||||
OnlineEvent.AppendText(at(), "до"),
|
OnlineEvent.AppendText(at(), "до"),
|
||||||
Event.ToolCall(at(), id = "t1", title = null, toolName = "grep", toolArgs = "{}"),
|
Event.ToolCall(at(), id = "t1", title = null, toolName = "grep", toolArgs = "{}"),
|
||||||
@@ -152,10 +152,10 @@ class LiveTurnTest {
|
|||||||
val bytes = byteArrayOf(1, 2, 3)
|
val bytes = byteArrayOf(1, 2, 3)
|
||||||
val turn = LiveTurn()
|
val turn = LiveTurn()
|
||||||
turn.feed(
|
turn.feed(
|
||||||
Event.Working(at()),
|
OnlineEvent.Working(at()),
|
||||||
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.IMAGE),
|
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.IMAGE),
|
||||||
OnlineEvent.AppendImage(at(), body = bytes, mime = "image/png"),
|
OnlineEvent.AppendImage(at(), body = bytes, mime = "image/png"),
|
||||||
Event.End(at()),
|
OnlineEvent.End(at()),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertTrue(turn.blocks.filterIsInstance<LiveBlock.Answer>().isEmpty())
|
assertTrue(turn.blocks.filterIsInstance<LiveBlock.Answer>().isEmpty())
|
||||||
@@ -168,12 +168,12 @@ class LiveTurnTest {
|
|||||||
fun `working resets the previous turn`() {
|
fun `working resets the previous turn`() {
|
||||||
val turn = LiveTurn()
|
val turn = LiveTurn()
|
||||||
turn.feed(
|
turn.feed(
|
||||||
Event.Working(at()),
|
OnlineEvent.Working(at()),
|
||||||
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
OnlineEvent.StartResponse(at(), OnlineEvent.ResponseType.TEXT),
|
||||||
OnlineEvent.AppendText(at(), "старое"),
|
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(1, turn.blocks.size)
|
||||||
assertEquals("новое", turn.answerText())
|
assertEquals("новое", turn.answerText())
|
||||||
@@ -182,7 +182,7 @@ class LiveTurnTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `append without explicit start response still streams as answer`() {
|
fun `append without explicit start response still streams as answer`() {
|
||||||
val turn = LiveTurn()
|
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(TurnPhase.RESPONDING, turn.phase)
|
||||||
assertEquals("без старта", turn.answerText())
|
assertEquals("без старта", turn.answerText())
|
||||||
@@ -193,7 +193,7 @@ class LiveTurnTest {
|
|||||||
val turn = LiveTurn()
|
val turn = LiveTurn()
|
||||||
var count = 0
|
var count = 0
|
||||||
turn.onChange = { count++ }
|
turn.onChange = { count++ }
|
||||||
turn.feed(Event.Working(at()), OnlineEvent.AppendText(at(), "x"))
|
turn.feed(OnlineEvent.Working(at()), OnlineEvent.AppendText(at(), "x"))
|
||||||
|
|
||||||
assertEquals(2, count)
|
assertEquals(2, count)
|
||||||
}
|
}
|
||||||
@@ -202,13 +202,13 @@ class LiveTurnTest {
|
|||||||
fun `preview text collapses whitespace and truncates`() {
|
fun `preview text collapses whitespace and truncates`() {
|
||||||
val turn = LiveTurn()
|
val turn = LiveTurn()
|
||||||
turn.feed(
|
turn.feed(
|
||||||
Event.Working(at()),
|
OnlineEvent.Working(at()),
|
||||||
OnlineEvent.AppendText(at(), "Первая строка\n\nвторая строка "),
|
OnlineEvent.AppendText(at(), "Первая строка\n\nвторая строка "),
|
||||||
)
|
)
|
||||||
assertEquals("Первая строка вторая строка", turn.previewText())
|
assertEquals("Первая строка вторая строка", turn.previewText())
|
||||||
|
|
||||||
val long = LiveTurn()
|
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)
|
val preview = long.previewText(maxLength = 30)
|
||||||
assertTrue(preview.length <= 31)
|
assertTrue(preview.length <= 31)
|
||||||
assertTrue(preview.endsWith("…"))
|
assertTrue(preview.endsWith("…"))
|
||||||
|
|||||||
Reference in New Issue
Block a user