9e888227a3
Семантика 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.
383 lines
15 KiB
Kotlin
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())
|
|
}
|
|
}
|
|
}
|