Коммит заменяет SQLDelight на ksqlite и добавляет journal/context/outbox
ci / JVM build + tests (push) Failing after 1m24s

This commit is contained in:
2026-09-21 01:12:31 +03:00
parent bd65c29b48
commit acc7237e51
61 changed files with 1623 additions and 1750 deletions
@@ -1,31 +1,24 @@
package pw.binom.agentik.storage.inmemory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.messageLog.MessageEvent
import pw.binom.agentik.messageLog.MessageRecord
import pw.binom.agentik.messageLog.MutableMessageStore
import pw.binom.agentik.messageLog.TokenStats
import kotlin.time.Instant
/**
* Thread-safe append-only лог сообщений в памяти.
*
* Хранит все записи в одном `List<MessageRecord>`, индексированном
* `conversationId`. `tokenStats` обходит только assistant-записи и
* суммирует `TurnTokens`.
* `conversationId`. Подходит для unit-тестов и ephemeral runtime (например,
* CLI-сессии или in-memory demo), где не нужен долгоживущий persistence.
*
* В отличие от SQLite-импла, не нуждается в SQLDelight и работает в
* любом KMP-таргете (включая iOS/native, где SQLite через Android driver
* недоступен).
* В отличие от ksqlite-импла, не требует native SQLite — работает в любом
* KMP-таргете (включая iOS/native).
*/
class InMemoryMessageStore : MutableMessageStore {
private val byConv: MutableMap<String, MutableList<MessageRecord>> = mutableMapOf()
private val events = MutableSharedFlow<MessageEvent>(extraBufferCapacity = 64)
private val mutex = Mutex()
override suspend fun append(record: MessageRecord) {
@@ -33,7 +26,6 @@ class InMemoryMessageStore : MutableMessageStore {
val list = byConv.getOrPut(record.conversationId) { mutableListOf() }
list.add(record)
}
events.tryEmit(MessageEvent.Appended(record.conversationId, record))
}
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord> {
@@ -45,30 +37,6 @@ class InMemoryMessageStore : MutableMessageStore {
}
}
override suspend fun listAll(conversationId: String): List<MessageRecord> {
mutex.withLock {
return byConv[conversationId].orEmpty().sortedBy { it.createdAt }.toList()
}
}
override fun events(): Flow<MessageEvent> = events.asSharedFlow()
override suspend fun tokenStats(conversationId: String): TokenStats {
mutex.withLock {
var input = 0L
var output = 0L
var turns = 0
for (rec in byConv[conversationId].orEmpty()) {
if (rec !is MessageRecord.AssistantMessage) continue
val t = rec.tokens ?: continue
input += t.input
output += t.output
turns += 1
}
return TokenStats(turns = turns, inputTokens = input, outputTokens = output)
}
}
override fun close() {
// no-op
}
@@ -2,28 +2,26 @@ package pw.binom.agentik.storage.inmemory
import pw.binom.agentik.messageLog.Content
import pw.binom.agentik.messageLog.MessageRecord
import pw.binom.agentik.messageLog.TurnTokens
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.yield
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
class InMemoryMessageStoreTest {
@Test
fun `append and listAll returns inserted records in createdAt order`() = runTest {
fun `append and listFlow returns inserted records in createdAt order`() = runTest {
val store = InMemoryMessageStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
val u = MessageRecord.UserMessage("u1", "c1", listOf(Content.Text("hi")), t0)
val a = MessageRecord.AssistantMessage("a1", "c1", listOf(Content.Text("hello")), t0.plus(kotlin.time.Duration.parse("PT1S")), null)
store.append(u)
store.append(a)
val all = store.listAll("c1")
val all = store.listFlow("c1", Instant.DISTANT_PAST).toList()
assertEquals(listOf("u1", "a1"), all.map { it.id })
}
@@ -51,82 +49,19 @@ class InMemoryMessageStoreTest {
}
@Test
fun `list returns empty for unknown conversation`() = runTest {
fun `listFlow returns empty for unknown conversation`() = runTest {
val store = InMemoryMessageStore()
assertEquals(emptyList(), store.listAll("none"))
assertTrue(store.listFlow("none", Instant.DISTANT_PAST).toList().isEmpty())
}
@Test
fun `tokenStats sums across assistant messages with tokens`() = runTest {
val store = InMemoryMessageStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append(
MessageRecord.AssistantMessage(
"a1", "c1", listOf(Content.Text("r")), t0.plus(kotlin.time.Duration.parse("PT1S")),
TurnTokens(input = 100, output = 50),
)
)
store.append(
MessageRecord.AssistantMessage(
"a2", "c1", listOf(Content.Text("r")), t0.plus(kotlin.time.Duration.parse("PT2S")),
TurnTokens(input = 200, output = 80),
)
)
// user без tokens
store.append(
MessageRecord.UserMessage("u1", "c1", listOf(Content.Text("hi")), t0)
)
val stats = store.tokenStats("c1")
assertEquals(2, stats.turns)
assertEquals(300L, stats.inputTokens)
assertEquals(130L, stats.outputTokens)
assertEquals(430L, stats.totalTokens)
}
@Test
fun `tokenStats skips assistant messages without tokens`() = runTest {
val store = InMemoryMessageStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append(
MessageRecord.AssistantMessage("a1", "c1", listOf(Content.Text("r")), t0, tokens = null)
)
val stats = store.tokenStats("c1")
assertEquals(0, stats.turns)
assertEquals(0L, stats.inputTokens)
}
@Test
fun `tokenStats returns zeros for unknown conversation`() = runTest {
val store = InMemoryMessageStore()
val stats = store.tokenStats("none")
assertEquals(0, stats.turns)
assertEquals(0L, stats.inputTokens)
}
@Test
fun `events flow emits Appended on append`() = runTest {
val store = InMemoryMessageStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
// SharedFlow не реплеит — запускаем коллектор ДО append, чтобы не потерять эвент.
val events = store.events()
val deferred = async { events.first() }
yield() // даём коллектору подписаться ДО append — иначе SharedFlow без replay потеряет эвент
store.append(MessageRecord.UserMessage("u1", "c1", listOf(Content.Text("hi")), t0))
val ev = deferred.await()
assertTrue(ev is pw.binom.agentik.messageLog.MessageEvent.Appended)
val appended = ev as pw.binom.agentik.messageLog.MessageEvent.Appended
assertEquals("c1", appended.conversationId)
assertEquals("u1", appended.record.id)
}
@Test
fun `isolates conversations - list returns only requested conv`() = runTest {
fun `isolates conversations - listFlow returns only requested conv`() = runTest {
val store = InMemoryMessageStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append(MessageRecord.UserMessage("u1", "c1", listOf(Content.Text("hi")), t0))
store.append(MessageRecord.UserMessage("u2", "c2", listOf(Content.Text("hello")), t0))
assertEquals(listOf("u1"), store.listAll("c1").map { it.id })
assertEquals(listOf("u2"), store.listAll("c2").map { it.id })
assertEquals(listOf("u1"), store.listFlow("c1", Instant.DISTANT_PAST).toList().map { it.id })
assertEquals(listOf("u2"), store.listFlow("c2", Instant.DISTANT_PAST).toList().map { it.id })
}
@Test
@@ -148,7 +83,7 @@ class InMemoryMessageStoreTest {
context = null,
)
store.append(msg)
val got = store.listAll("c1").first() as MessageRecord.UserMessage
val got = store.listFlow("c1", Instant.DISTANT_PAST).first() as MessageRecord.UserMessage
assertNull(got.context)
}
}