Коммит заменяет SQLDelight на ksqlite и добавляет journal/context/outbox
ci / JVM build + tests (push) Failing after 1m24s
ci / JVM build + tests (push) Failing after 1m24s
This commit is contained in:
+4
-36
@@ -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
|
||||
}
|
||||
|
||||
+9
-74
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user