Files
agentik/ksqlite-message-storage/src/commonTest/kotlin/pw/binom/MessageStoreTest.kt
T
subochev 9e888227a3 refactor: outbox становится live-only стримом, cursor вынесен в :cursor-api
Семантика outbox'а — теперь чистый live-канал:
  * OutboxStore.conversationEvents(id) — больше не принимает after-cursor.
    Catchup (replay) делает клиент: journal.listFlow(afterSeq) + подписка
    на live. Outbox ответственен только за уведомления «что-то произошло».
  * OutboxStore.oldestCursor()/currentCursor()/OutboxGapException — удалены.
    Эпоха и offset живут ТОЛЬКО в CursorHolder/CursorStore, переживают
    рестарт и инкремент для каждого commit.
  * DurableEvent: commit принимает блок { cursor -> MessageRecord }
    (cursor выдаёт CursorStore; клиент не вычисляет offset сам).
  * MessageRecord больше не несёт cursor — это не его ответственность.

Новые модули:
  * :cursor-api         — Cursor(epoch, offset) + CursorHolder / MutableCursorHolder
  * :cursor-ksqlite     — KsqliteCursorHolder (персистентный)
  * :cursor-inmemory    — для тестов
  * :client-sync        — LocalSyncAgent (мини-агент поверх :client для десктопа)

Удалены:
  * :sync-core          — старая референсная реализация, заменена
                           cursor-разделением и :client.
  * outbox-ksqlite      — CursorStore/Schema уехали в :cursor-ksqlite.
  * OffsetSequencer / PersistentOffsetSequencer / InMemoryOffsetSequencer.

standalone:
  * ChatAgent/ConversationLoop/DurableLog/ToolDispatcher/ReflectionScheduler/
    ConversationEvents — подписка через push-паттерн (collect событий).
  * A2aBridge — currentCursor() и conversationEvents(after=) убраны.
  * SqliteStores — cursor_offset удалён из schema v4; seedNextFromJournal
    читает MAX(created_at).
  * Main.kt — outboxSequencer → outboxCursorHolder; user→agent (:server)
    transport удалён; debug-routes удалены; A2A остался.
  * Тесты ChatAgentTest/PersistenceTest переписаны на push-паттерн
    (subscribe-before-act, snapshot∪live = итоговое состояние). 25/25 + 19/19 ✅

server / client:
  * Routes эпоху читают из CursorHolder; снимки несут Cursor? для catchup.
  * AgentikAgent и HttpEventStore — те же подписки, без after-параметра.
  * ReconnectingOutbox / ReconnectingOutboxTest — без изменений API.
  * JournalStore API расширен count(after=Instant?) для unread-badge.
2026-10-05 23:33:35 +03:00

383 lines
15 KiB
Kotlin

package pw.binom
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.content.Content
import pw.binom.agentik.cursor.Cursor
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
private const val EPOCH = 1_700_000_000_000L
/**
* Полный набор тестов [MessageStore] на in-memory SQLite: все ветки push, проекция,
* курсорная фильтрация, слияние двух таблиц, пагинация, каскадное удаление, откат транзакции,
* границы журнала и round-trip содержимого.
*/
class MessageStoreTest {
private var counter = 0UL
private val t0 = Instant.fromEpochMilliseconds(EPOCH)
private fun nextCursor() = Cursor(createdAt = EPOCH, offset = ++counter)
private fun cursorAt(offset: ULong) = Cursor(createdAt = EPOCH, offset = offset)
private suspend fun withStore(block: suspend (MessageStore) -> Unit) {
val conn = SQLiteConnection.memory("message-store-${kotlin.random.Random.nextLong()}")
val store = MessageStore(connection = conn, ownsConnection = false)
try {
block(store)
} finally {
store.close()
conn.close()
}
}
// ---- пустое хранилище -------------------------------------------------------------------
@Test
fun emptyStoreHasNoStateAndEmptyStreams() = runTest {
withStore { store ->
assertNull(store.minCursor)
assertNull(store.maxCursor)
assertTrue(store.getState(nextCursor()).toList().isEmpty())
assertTrue(store.get(nextCursor()).getOrException().toList().isEmpty())
}
}
@Test
fun clearOnEmptyStoreIsNoop() = runTest {
withStore { store ->
store.clear()
assertNull(store.minCursor)
assertNull(store.maxCursor)
assertTrue(store.getState(nextCursor()).toList().isEmpty())
}
}
// ---- курсоры ---------------------------------------------------------------------------
@Test
fun minAndMaxCursorTrackPushes() = runTest {
withStore { store ->
assertNull(store.minCursor)
assertNull(store.maxCursor)
store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(3u))
assertEquals(cursorAt(3u), store.minCursor)
assertEquals(cursorAt(3u), store.maxCursor)
store.push(MessageEvent.RenameConversation("c", "T2"), cursorAt(7u))
assertEquals(cursorAt(3u), store.minCursor)
assertEquals(cursorAt(7u), store.maxCursor)
}
}
@Test
fun pushRejectsNonIncreasingCursorAndRollsBackProjection() = runTest {
withStore { store ->
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("a"))), cursorAt(5u))
assertFailsWith<IllegalArgumentException> {
store.push(
MessageEvent.AddUserMessage("m2", t0, "c", listOf(Content.Text("b"))),
cursorAt(5u),
)
}
assertFailsWith<IllegalArgumentException> {
store.push(
MessageEvent.AddUserMessage("m3", t0, "c", listOf(Content.Text("c"))),
cursorAt(4u),
)
}
// Проекция откатилась: только первое сообщение, курсор на месте.
assertEquals(
listOf<MessageRecord>(MessageRecord.UserMessage("m1", t0, listOf(Content.Text("a")), "c")),
store.getState(cursorAt(5u)).toList(),
)
assertEquals(cursorAt(5u), store.maxCursor)
}
}
@Test
fun clearResetsCursorSoLowerCursorAccepted() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(9u))
store.clear()
assertNull(store.minCursor)
assertNull(store.maxCursor)
store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(1u))
assertEquals(cursorAt(1u), store.minCursor)
assertEquals(cursorAt(1u), store.maxCursor)
}
}
// ---- беседа: create / rename / delete --------------------------------------------------
@Test
fun conversationLifecycleUpdatesCursorOnChange() = runTest {
withStore { store ->
val created = nextCursor()
store.push(MessageEvent.CreateConversation("c1", "First"), created)
assertEquals(
listOf<MessageRecord>(MessageRecord.Conversation("c1", "First")),
store.getState(created).toList(),
)
val renamed = nextCursor()
store.push(MessageEvent.RenameConversation("c1", "Renamed"), renamed)
assertEquals(
listOf<MessageRecord>(MessageRecord.Conversation("c1", "Renamed")),
store.getState(renamed).toList(),
)
// Изменённая позже строка выпадает из состояния на более раннем курсоре.
assertTrue(store.getState(created).toList().isEmpty())
val deleted = nextCursor()
store.push(MessageEvent.DeleteConversation("c1"), deleted)
assertTrue(store.getState(deleted).toList().isEmpty())
}
}
@Test
fun deletingConversationRemovesItsMessages() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("a", "A"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "a", listOf(Content.Text("1"))), nextCursor())
store.push(MessageEvent.CreateConversation("b", "B"), nextCursor())
store.push(MessageEvent.AddUserMessage("m2", t0, "b", listOf(Content.Text("2"))), nextCursor())
store.push(MessageEvent.DeleteConversation("a"), nextCursor())
// Сообщения удалённой беседы не остаются сиротами в состоянии.
assertEquals(
listOf<MessageRecord>(
MessageRecord.Conversation("b", "B"),
MessageRecord.UserMessage("m2", t0, listOf(Content.Text("2")), "b"),
),
store.getState(store.maxCursor!!).toList(),
)
}
}
@Test
fun deletingConversationKeepsJournalEvents() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("a", "A"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "a", listOf(Content.Text("1"))), nextCursor())
store.push(MessageEvent.DeleteConversation("a"), nextCursor())
// Журнал append-only: удаление проекции не трогает историю событий.
val events = store.get(cursorAt(1u)).getOrException().toList()
assertEquals(3, events.size)
assertTrue(events.last().data is MessageEvent.DeleteConversation)
}
}
@Test
fun deletingUnknownConversationIsNoop() = runTest {
withStore { store ->
store.push(MessageEvent.DeleteConversation("nope"), nextCursor())
assertTrue(store.getState(store.maxCursor!!).toList().isEmpty())
}
}
// ---- сообщения: все типы + содержимое --------------------------------------------------
@Test
fun allMessageTypesInCursorOrder() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("c", "T"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), nextCursor())
store.push(MessageEvent.AddAssistantMessage("m2", t0, "c", listOf(Content.Text("yo"))), nextCursor())
store.push(MessageEvent.AddToolCall("m3", t0, "c", "search", "{}"), nextCursor())
store.push(MessageEvent.AddToolResponse("m4", t0, "c", "m3", "ok"), nextCursor())
store.push(MessageEvent.AddToolFail("m5", t0, "c", "m3", "err"), nextCursor())
assertEquals(
listOf<MessageRecord>(
MessageRecord.Conversation("c", "T"),
MessageRecord.UserMessage("m1", t0, listOf(Content.Text("hi")), "c"),
MessageRecord.AssistantMessage("m2", t0, "c", listOf(Content.Text("yo"))),
MessageRecord.ToolCall("m3", t0, "c", "search", "{}"),
MessageRecord.ToolResponse("m4", t0, "c", "m3", "ok"),
MessageRecord.ToolFail("m5", t0, "c", "m3", "err"),
),
store.getState(store.maxCursor!!).toList(),
)
}
}
@Test
fun imageContentRoundTrips() = runTest {
withStore { store ->
val image = Content.Image(byteArrayOf(1, 2, 3, -1, 0, 7), "image/png")
store.push(
MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("see"), image)),
nextCursor(),
)
assertEquals(
listOf<MessageRecord>(
MessageRecord.UserMessage("m", t0, listOf(Content.Text("see"), image), "c"),
),
store.getState(store.maxCursor!!).toList(),
)
}
}
@Test
fun duplicateMessageIdIsReplacedAtLaterCursor() = runTest {
withStore { store ->
store.push(MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("v1"))), nextCursor())
store.push(MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("v2"))), nextCursor())
assertEquals(
listOf<MessageRecord>(MessageRecord.UserMessage("m", t0, listOf(Content.Text("v2")), "c")),
store.getState(store.maxCursor!!).toList(),
)
}
}
// ---- состояние: фильтр по курсору и слияние --------------------------------------------
@Test
fun stateIsFilteredByCursor() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("c", "T"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("1"))), nextCursor())
store.push(MessageEvent.AddUserMessage("m2", t0, "c", listOf(Content.Text("2"))), nextCursor())
assertEquals(3, store.getState(cursorAt(3u)).toList().size)
assertEquals(2, store.getState(cursorAt(2u)).toList().size)
assertTrue(store.getState(cursorAt(0u)).toList().isEmpty())
}
}
@Test
fun stateCursorAboveMaxReturnsEverything() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("c", "T"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("x"))), nextCursor())
assertEquals(2, store.getState(cursorAt(999u)).toList().size)
// Курсор из другой (более старой) эпохи не видит строк текущей.
assertTrue(store.getState(Cursor(EPOCH - 1, 999u)).toList().isEmpty())
}
}
@Test
fun interleavedTablesMergeInCursorOrder() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("a", "A"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "a", listOf(Content.Text("x"))), nextCursor())
store.push(MessageEvent.CreateConversation("b", "B"), nextCursor())
store.push(MessageEvent.AddAssistantMessage("m2", t0, "b", listOf(Content.Text("y"))), nextCursor())
assertEquals(
listOf<MessageRecord>(
MessageRecord.Conversation("a", "A"),
MessageRecord.UserMessage("m1", t0, listOf(Content.Text("x")), "a"),
MessageRecord.Conversation("b", "B"),
MessageRecord.AssistantMessage("m2", t0, "b", listOf(Content.Text("y"))),
),
store.getState(store.maxCursor!!).toList(),
)
}
}
// ---- пагинация -------------------------------------------------------------------------
@Test
fun getStatePaginatesAcrossPageBoundary() = runTest {
withStore { store ->
repeat(100) { i ->
store.push(MessageEvent.CreateConversation("c$i", "T$i"), nextCursor())
}
assertEquals(100, store.getState(store.maxCursor!!).toList().size)
store.push(MessageEvent.CreateConversation("c100", "T100"), nextCursor())
assertEquals(101, store.getState(store.maxCursor!!).toList().size)
}
}
@Test
fun interleavedTablesMergeAcrossPages() = runTest {
withStore { store ->
repeat(120) { i ->
store.push(MessageEvent.CreateConversation("c$i", "T$i"), nextCursor())
store.push(MessageEvent.AddUserMessage("m$i", t0, "c$i", listOf(Content.Text("$i"))), nextCursor())
}
val records = store.getState(store.maxCursor!!).toList()
assertEquals(240, records.size)
for (i in 0 until 120) {
assertEquals(
MessageRecord.Conversation("c$i", "T$i"),
records[i * 2],
)
assertEquals(
MessageRecord.UserMessage("m$i", t0, listOf(Content.Text("$i")), "c$i"),
records[i * 2 + 1],
)
}
}
}
// ---- журнал: get / expired -------------------------------------------------------------
@Test
fun journalKeepsEventsInOrder() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(1u))
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), cursorAt(2u))
val events = store.get(cursorAt(1u)).getOrException().toList()
assertEquals(listOf(1uL, 2uL), events.map { it.seq.offset })
assertTrue(events[0].data is MessageEvent.CreateConversation)
assertTrue(events[1].data is MessageEvent.AddUserMessage)
}
}
@Test
fun journalGetIsInclusiveAtMinAndExpiredBelow() = runTest {
withStore { store ->
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), cursorAt(5u))
val atMin = store.get(cursorAt(5u)).getOrException().toList()
assertEquals(1, atMin.size)
assertEquals(5uL, atMin[0].seq.offset)
assertTrue(store.get(cursorAt(1u)).isExpired)
}
}
// ---- clear -----------------------------------------------------------------------------
@Test
fun clearRemovesStateAndJournal() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("c", "T"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), nextCursor())
store.clear()
assertNull(store.minCursor)
assertNull(store.maxCursor)
assertTrue(store.getState(nextCursor()).toList().isEmpty())
assertTrue(store.get(nextCursor()).getOrException().toList().isEmpty())
}
}
}