Коммит заменяет 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
+8 -1
View File
@@ -22,6 +22,13 @@ val skipVectorMemory: Boolean =
kotlin {
jvmToolchain(21)
// Отключаем auto-propagation Apple-таргетов от KMP-зависимостей (`:proto`,
// `:server`, `:agent-toolsets` имеют Apple-варианты). `:standalone` JVM-only,
// Apple таргеты не нужны и ломают резолюцию `:message-store-api` в appleMain
// (он KMP jvm+linuxX64+mingwX64 без Apple).
// @OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
// applyDefaultHierarchyTemplate { }
jvm {
binaries {
executable {
@@ -61,7 +68,7 @@ kotlin {
}
implementation(project(":message-store-api"))
implementation(project(":working-memory-api"))
implementation(project(":storage-sqlite"))
implementation(project(":storage-ksqlite"))
// Новый единый канал событий агента — заменил старые
// `agentEvents: MutableSharedFlow<AgentEvent>` и per-conv `ConversationEvents._flow`.
@@ -10,7 +10,6 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.messageStore.ReflectionStore
import pw.binom.agentik.messageLog.MessageStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.standalone.agent.ChatConversation
import pw.binom.agentik.llm.tools.LlmReflector
@@ -27,7 +26,10 @@ import pw.binom.agentik.workingMemory.WorkingMemoryStore
* - `POST /debug/skill-mine?conversationId=...` — прогон [SkillMiner] прямо сейчас
* - `POST /debug/curate` — прогон [Curator.runPass] прямо сейчас
* - `POST /debug/compact?conversationId=...` — принудительный compaction
* - `GET /debug/tokens?conversationId=...` — token-статистика диалога из БД
*
* Token-stats эндпоинт убран вместе с `MessageStore.tokenStats()` (см.
* :message-log-api/MessageStore.kt). Сейчас token accounting доступен
* только через assistant-сообщения с `TurnTokens` (см. MessageRecord).
*
* Каждый возвращает JSON с результатом (что сохранил / нашёл / сжал), чтобы в
* тестах было видно не только "триггер сработал", а что именно LLM намайнила.
@@ -35,7 +37,6 @@ import pw.binom.agentik.workingMemory.WorkingMemoryStore
*/
internal fun Route.debugRoutes(
agent: Agent,
messageStore: MessageStore,
workingMemoryStore: WorkingMemoryStore,
reflectionStore: ReflectionStore,
reflector: LlmReflector?,
@@ -110,20 +111,6 @@ internal fun Route.debugRoutes(
val ok = chatConv.forceCompactNow()
call.respondText("""{"compacted":$ok}""", contentType = ContentType.Application.Json)
}
get("/debug/tokens") {
val convId = call.parameters["conversationId"]
?: return@get call.respondText("conversationId required", status = HttpStatusCode.BadRequest)
val stats = messageStore.tokenStats(convId)
val json = buildJsonObject {
put("conversationId", convId)
put("turns", stats.turns.toString())
put("inputTokens", stats.inputTokens.toString())
put("outputTokens", stats.outputTokens.toString())
put("totalTokens", (stats.inputTokens + stats.outputTokens).toString())
}.toString()
call.respondText(json, contentType = ContentType.Application.Json)
}
}
/**
@@ -29,7 +29,7 @@ import pw.binom.agentik.standalone.config.AppConfig.MemoryBackend
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.ModelDownloader
import pw.binom.agentik.mcp.bridge.McpRegistry
import pw.binom.agentik.storage.sqlite.SqliteStores
import pw.binom.agentik.storage.ksqlite.KsqliteStores
import java.io.File
import pw.binom.agentik.llm.tools.SkillMiner
/**
@@ -198,7 +198,7 @@ private fun runServer() {
}
val llm = config.llm.createLlm()
val sqliteStores = SqliteStores.open(dbPath = config.agent.dbPath)
val sqliteStores = KsqliteStores.open(path = config.agent.dbPath)
val mcpRegistry = McpRegistry.fromConfig(config.mcp)
// Хранилище скилов: если skillsDir задан, читаем каталог + создаём
@@ -368,7 +368,6 @@ private fun runServer() {
if (config.debug.endpoints) {
debugRoutes(
agent = agent,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
reflector = reflector,
@@ -405,24 +404,13 @@ private fun runServer() {
if (config.debug.endpoints) {
println(" debug endpoints: enabled (/debug/reflect, /debug/skill-mine, /debug/curate, /debug/compact, /debug/tokens)")
}
// Token stats по существующим диалогам (агрегат на старте — каждая запись
// парсится из payload_json, ну >100 turns и БД приличная — но в рамках
// стартапа это терпимо).
// Раньше здесь был агрегат tokenStats() по всем conv'ам при старте. Метод
// убран из :message-log-api (MessageStore стал чисто read-only list+listFlow);
// см. agentik :message-log-api/MessageStore.kt. Token accounting теперь
// доступен через assistant-сообщения с TurnTokens (см. MessageRecord.AssistantMessage).
val existingConvs = kotlinx.coroutines.runBlocking { sqliteStores.conversations.list(offset = 0, limit = 1000) }
if (existingConvs.isNotEmpty()) {
var totalTurns = 0
var totalIn = 0L
var totalOut = 0L
for (c in existingConvs) {
if (c.isTemporal) continue
val s = kotlinx.coroutines.runBlocking { sqliteStores.messages.tokenStats(c.id) }
totalTurns += s.turns
totalIn += s.inputTokens
totalOut += s.outputTokens
}
if (totalTurns > 0) {
println(" tokens: ${existingConvs.size} convs, $totalTurns turns, in=${totalIn}, out=${totalOut}, total=${totalIn + totalOut}")
}
println(" conversations: ${existingConvs.size} (active)")
}
Runtime.getRuntime().addShutdownHook(Thread {
agent.close()
@@ -196,8 +196,23 @@ class ConversationLoop(
}
override suspend fun interrupt() {
if (activeTurn?.isActive != true) {
log.info { "interrupt() no-op: no active turn for $id" }
// Всегда ставим флаг — даже если activeTurn ещё не стартовал.
// runTurn проверяет interrupted.get() при входе (short-circuit) и в
// каждой итерации цикла + finally. Если turn запустится ПОСЛЕ нашего
// interrupt() — он увидит флаг на entry и сразу завершится без
// реального LLM-вызова. Если turn уже идёт — Interrupted + End придут
// в finally.
//
// Раньше здесь был early-return при `!activeTurn?.isActive`, но это
// давало race с точки зрения тестов: send() может завершиться
// быстрее (например, на ksqlite-бэкенде, где messageStore.append
// практически мгновенный), и interrupt(), вызванный после
// delay(200) от launch send(), видел completed Job → no-op →
// Interrupted event не эмитится.
val wasActive = activeTurn?.isActive
if (wasActive != null && !wasActive) {
// Предыдущий turn уже завершился — interrupt() действительно no-op.
log.info { "interrupt() no-op: previous turn already completed for $id" }
return
}
interrupted.set(true)
@@ -16,7 +16,7 @@ import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.messageLog.MessageRecord
import pw.binom.agentik.workingMemory.WorkingMemoryEntry
import pw.binom.agentik.storage.sqlite.SqliteStores
import pw.binom.agentik.storage.ksqlite.KsqliteStores
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
@@ -41,12 +41,12 @@ import pw.binom.agentik.toolsets.NamedTool
class ChatAgentTest {
private lateinit var sqliteStores: pw.binom.agentik.storage.sqlite.SqliteStores
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
private lateinit var fakeLlm: FakeLiteLlm
@BeforeTest
fun setup() {
sqliteStores = SqliteStores.inMemory()
sqliteStores = KsqliteStores.inMemory("chat-${kotlin.random.Random.nextLong()}")
fakeLlm = FakeLiteLlm()
}
@@ -56,7 +56,7 @@ class ChatAgentTest {
}
private fun newAgent(
sqliteStores: pw.binom.agentik.storage.sqlite.SqliteStores = this.sqliteStores,
sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores = this.sqliteStores,
llm: LiteLlm = this.fakeLlm,
tools: List<NamedTool> = emptyList(),
skills: SkillCatalog = SkillCatalog.EMPTY,
@@ -173,7 +173,7 @@ class ChatAgentTest {
assertTrue(agent.deleteConversation(id))
assertNull(agent.getConversation(id))
assertNull(sqliteStores.conversations.get(id))
assertEquals(emptyList(), sqliteStores.messages.listAll(id))
assertEquals(emptyList(), sqliteStores.messages.listFlow(id, Instant.DISTANT_PAST).toList())
}
@Test
@@ -191,7 +191,7 @@ class ChatAgentTest {
conv.send(listOf(Content.Text("hi")))
// user message записан в audit + working memory
val msgs = sqliteStores.messages.listAll(conv.id)
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
assertEquals(2, msgs.size)
assertEquals("hi", (msgs[0] as pw.binom.agentik.messageLog.MessageRecord.UserMessage).content.let {
(it[0] as pw.binom.agentik.messageLog.Content.Text).body
@@ -328,7 +328,7 @@ class ChatAgentTest {
assertTrue(events.any { it is ProtoEvent.Error && it.message == "boom from llm" }, "events=$events")
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
val msgs = sqliteStores.messages.listAll(conv.id)
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
assertEquals(2, msgs.size)
assertIs<pw.binom.agentik.messageLog.MessageRecord.UserMessage>(msgs[0])
val err = assertIs<pw.binom.agentik.messageLog.MessageRecord.Error>(msgs[1])
@@ -373,7 +373,7 @@ class ChatAgentTest {
eventsJob.cancel()
// audit: только user (assistant не успел сгенериться)
val msgs = sqliteStores.messages.listAll(conv.id)
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
assertEquals(1, msgs.size)
assertIs<pw.binom.agentik.messageLog.MessageRecord.UserMessage>(msgs[0])
@@ -430,7 +430,7 @@ class ChatAgentTest {
eventsJob.cancel()
// audit: user + toolcall + toolresult (tool выполнился), assistant может быть
val msgs = sqliteStores.messages.listAll(conv.id)
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
val toolResult = msgs.filterIsInstance<pw.binom.agentik.messageLog.MessageRecord.ToolResult>().firstOrNull()
assertNotNull(toolResult, "tool result должен быть в audit — tool выполнился нормально")
val toolResultResult = toolResult!!.result!!
@@ -455,7 +455,7 @@ class ChatAgentTest {
// Поднимаем file-backed БД, создаём temp-беседу
sqliteStores.close()
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
sqliteStores = SqliteStores.open(dbPath)
sqliteStores = KsqliteStores.open(dbPath)
val agent1 = ChatAgent(
id = "agentik",
conversationStore = sqliteStores.conversations,
@@ -475,7 +475,7 @@ class ChatAgentTest {
// Переоткрываем БД — temp-беседа не должна пережить рестарт
sqliteStores.close()
sqliteStores = SqliteStores.open(dbPath)
sqliteStores = KsqliteStores.open(dbPath)
val agent2 = ChatAgent(
id = "agentik",
conversationStore = sqliteStores.conversations,
@@ -497,7 +497,7 @@ class ChatAgentTest {
fun `non-temp conversation persists across agent instances`() = runTest {
sqliteStores.close()
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
sqliteStores = SqliteStores.open(dbPath)
sqliteStores = KsqliteStores.open(dbPath)
val agent1 = ChatAgent(
id = "agentik",
conversationStore = sqliteStores.conversations,
@@ -515,7 +515,7 @@ class ChatAgentTest {
val id = conv.id
sqliteStores.close()
sqliteStores = SqliteStores.open(dbPath)
sqliteStores = KsqliteStores.open(dbPath)
val agent2 = ChatAgent(
id = "agentik",
conversationStore = sqliteStores.conversations,
@@ -2,7 +2,7 @@ package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.storage.sqlite.SqliteStores
import pw.binom.agentik.storage.ksqlite.KsqliteStores
import pw.binom.agentik.toolsets.ToolsetContribution
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteTool
@@ -39,8 +39,8 @@ class ChatAgentToolsetsTest {
private fun newAgent(
toolsets: List<ToolsetContribution> = emptyList(),
): Pair<ChatAgent, pw.binom.agentik.storage.sqlite.SqliteStores> {
val sqliteStores = SqliteStores.inMemory()
): Pair<ChatAgent, pw.binom.agentik.storage.ksqlite.KsqliteStores> {
val sqliteStores = KsqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}")
val agent = ChatAgent(
id = "test-agent",
conversationStore = sqliteStores.conversations,
@@ -11,7 +11,7 @@ import pw.binom.agentik.memory.md.KeywordMdReviewer
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.llm.OpenAiConfig
import pw.binom.agentik.storage.sqlite.SqliteStores
import pw.binom.agentik.storage.ksqlite.KsqliteStores
import pw.binom.agentik.proto.Content as ProtoContent
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
@@ -40,12 +40,12 @@ import pw.binom.agentik.llm.tools.SummaryTurn
*/
class CompactionTest {
private lateinit var sqliteStores: pw.binom.agentik.storage.sqlite.SqliteStores
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
private lateinit var fakeLlm: FakeLiteLlm
@BeforeTest
fun setup() {
sqliteStores = SqliteStores.inMemory()
sqliteStores = KsqliteStores.inMemory("compact-${kotlin.random.Random.nextLong()}")
fakeLlm = FakeLiteLlm()
}
@@ -30,7 +30,7 @@ import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.llm.OpenAiConfig
import pw.binom.agentik.storage.sqlite.SqliteStores
import pw.binom.agentik.storage.ksqlite.KsqliteStores
import pw.binom.agentik.llm.tools.ContextCompactor
import pw.binom.agentik.llm.tools.SummaryTurn
import kotlin.test.AfterTest
@@ -49,13 +49,13 @@ import kotlin.time.Instant
*/
class MemoryWiringTest {
private lateinit var sqliteStores: pw.binom.agentik.storage.sqlite.SqliteStores
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
private lateinit var fakeLlm: FakeLiteLlm
private lateinit var root: Path
@BeforeTest
fun setup() {
sqliteStores = SqliteStores.inMemory()
sqliteStores = KsqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}")
fakeLlm = FakeLiteLlm()
root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}")
SystemFileSystem.createDirectories(root, mustCreate = true)
@@ -6,8 +6,9 @@ import pw.binom.agentik.messageLog.MessageRecord
import pw.binom.agentik.messageLog.Content
import pw.binom.agentik.workingMemory.WorkingMemoryEntry
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.storage.sqlite.SqliteStores
import pw.binom.agentik.storage.ksqlite.KsqliteStores
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -21,11 +22,11 @@ import kotlin.time.Instant
class PersistenceTest {
private lateinit var stores: SqliteStores
private lateinit var stores: KsqliteStores
@BeforeTest
fun setup() {
stores = SqliteStores.inMemory()
stores = KsqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}")
}
@AfterTest
@@ -109,13 +110,13 @@ class PersistenceTest {
),
now = t0,
)
assertEquals(1, stores.messages.listAll("c1").size)
assertEquals(1, stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList().size)
assertEquals(1, stores.workingMemory.list("c1").size)
val removed = stores.conversations.delete("c1")
assertTrue(removed)
assertNull(stores.conversations.get("c1"))
assertEquals(emptyList(), stores.messages.listAll("c1"))
assertEquals(emptyList(), stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList())
assertEquals(emptyList(), stores.workingMemory.list("c1"))
}
@@ -129,7 +130,7 @@ class PersistenceTest {
MessageRecord.AssistantMessage("m2", "c1", listOf(Content.Text("yo")), t0),
)
val all = stores.messages.listAll("c1")
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
assertEquals(2, all.size)
assertEquals("m1", all[0].id)
assertEquals("m2", all[1].id)
@@ -284,7 +285,7 @@ class PersistenceTest {
createdAt = Instant.fromEpochMilliseconds(1_700_000_001_000),
),
)
val all = stores.messages.listAll("c1")
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
assertEquals(2, all.size)
val first = assertIs<MessageRecord.Error>(all[0])
assertEquals("e1", first.id)
@@ -305,7 +306,7 @@ class PersistenceTest {
createdAt = t0,
),
)
val all = stores.messages.listAll("c1")
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
val image = (all[0] as MessageRecord.UserMessage).content[0] as Content.Image
assertEquals("image/png", image.mime)
assertTrue(bytes.contentEquals(image.data))
@@ -328,7 +329,7 @@ class PersistenceTest {
context = ctx,
),
)
val all = stores.messages.listAll("c1")
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
assertEquals(1, all.size)
val user = assertIs<MessageRecord.UserMessage>(all[0])
assertEquals(ctx, user.context)
@@ -345,7 +346,7 @@ class PersistenceTest {
createdAt = t0,
),
)
val all = stores.messages.listAll("c1")
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
val user = assertIs<MessageRecord.UserMessage>(all[0])
assertNull(user.context)
}
@@ -1,48 +0,0 @@
package pw.binom.agentik.standalone.persistence
import pw.binom.agentik.messageStore.Reflection
import pw.binom.agentik.messageStore.ConversationRecord
import pw.binom.agentik.messageLog.MessageRecord
import pw.binom.agentik.messageLog.Content
import pw.binom.agentik.workingMemory.WorkingMemoryEntry
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import pw.binom.agentik.storage.sqlite.SqliteStores
class SqliteStoresMigrationTest {
@Test
fun `reflection table is created on fresh inMemory database`() {
val stores = SqliteStores.inMemory()
try {
// Если таблицы нет — insert упадёт. Проверяем insert round-trip.
val r = Reflection(
id = "m1",
conversationId = null,
createdAt = kotlin.time.Instant.parse("2026-09-15T12:00:00Z"),
turnsAnalyzed = 3,
score = 5,
summary = "fresh",
weakSpots = listOf("none"),
)
kotlinx.coroutines.runBlocking { stores.reflections.insert(r) }
val loaded = kotlinx.coroutines.runBlocking { stores.reflections.get("m1") }
assertNotNull(loaded)
assertEquals("fresh", loaded.summary)
} finally { stores.close() }
}
@Test
fun `all stores exposed`() {
val stores = SqliteStores.inMemory()
try {
assertNotNull(stores.conversations)
assertNotNull(stores.messages)
assertNotNull(stores.workingMemory)
assertNotNull(stores.reflections)
assertTrue(stores.driver.toString().isNotBlank())
} finally { stores.close() }
}
}
@@ -1,137 +0,0 @@
package pw.binom.agentik.standalone.persistence
import pw.binom.agentik.messageLog.TurnTokens
import pw.binom.agentik.messageLog.MessageBodyPayload
import pw.binom.agentik.messageLog.encodeBodyPayload
import pw.binom.agentik.messageLog.decodeBodyPayload
import pw.binom.agentik.messageLog.MessageContext
import pw.binom.agentik.messageLog.MessageOrigin
import pw.binom.agentik.messageStore.ConversationRecord
import pw.binom.agentik.messageLog.MessageRecord
import pw.binom.agentik.messageLog.Content
import pw.binom.agentik.workingMemory.WorkingMemoryEntry
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.time.Instant
import pw.binom.agentik.storage.sqlite.SqliteStores
class TokenStatsTest {
@Test
fun `tokenStats sums across assistant messages of same conversation`() {
val stores = SqliteStores.inMemory()
try {
kotlinx.coroutines.runBlocking {
stores.conversations.upsert(
ConversationRecord(
id = "c1",
title = null,
isTemporal = false,
createdAt = Instant.parse("2026-09-15T12:00:00Z"),
updatedAt = Instant.parse("2026-09-15T12:00:00Z"),
)
)}
kotlinx.coroutines.runBlocking {
stores.messages.append(
assistant("a1", "c1", 100, 50, Instant.parse("2026-09-15T12:01:00Z"))
)
stores.messages.append(
assistant("a2", "c1", 200, 80, Instant.parse("2026-09-15T12:02:00Z"))
)
// User без tokens — не должны считаться.
stores.messages.append(
MessageRecord.UserMessage(
id = "u1",
conversationId = "c1",
content = listOf(Content.Text("hi")),
createdAt = Instant.parse("2026-09-15T12:00:30Z"),
)
)
}
val stats = kotlinx.coroutines.runBlocking { stores.messages.tokenStats("c1") }
assertEquals(2, stats.turns)
assertEquals(300L, stats.inputTokens)
assertEquals(130L, stats.outputTokens)
assertEquals(430L, stats.totalTokens)
} finally { stores.close() }
}
@Test
fun `tokenStats skips legacy assistant messages without tokens field`() {
val stores = SqliteStores.inMemory()
try {
// Вставляем запись со СТАРЫМ payload-форматом (plain array через
// прямой SQL апдейт, минуя типизированный encoder).
val id = "legacy"
val msgId = "m1"
stores.driver.execute(null, "INSERT INTO conversation(id, title, is_temporal, created_at, updated_at) VALUES('c1', NULL, 0, 0, 0)", 0)
stores.driver.execute(
null,
"INSERT INTO message(id, conversation_id, kind, payload_json, created_at) VALUES(?, ?, ?, ?, ?)",
5,
) {
bindString(0, msgId)
bindString(1, "c1")
bindString(2, "assistant")
bindString(3, """[{"kind":"text","body":"legacy"}]""")
bindLong(4, Instant.parse("2026-09-15T12:00:00Z").toEpochMilliseconds())
}
val stats = kotlinx.coroutines.runBlocking { stores.messages.tokenStats("c1") }
assertEquals(0, stats.turns, "legacy без tokens не должен считаться")
assertEquals(0L, stats.inputTokens)
assertEquals(0L, stats.outputTokens)
} finally { stores.close() }
}
@Test
fun `TurnTokens rejects negative values`() {
val tokens = TurnTokens(input = 100, output = 50)
assertEquals(100, tokens.input)
assertEquals(150, tokens.total)
// Sanity для init{}
try {
TurnTokens(input = -1, output = 50)
error("should have thrown")
} catch (_: IllegalArgumentException) {}
}
@Test
fun `backwards compatible encode-decode round trip preserves both context and tokens`() {
val original = MessageBodyPayload(
content = listOf(Content.Text("hi")),
context = MessageContext(
origin = MessageOrigin.USER,
description = "x",
),
tokens = TurnTokens(input = 100, output = 50),
)
val json = kotlinx.serialization.json.Json.encodeToString(MessageBodyPayload.serializer(), original)
val decoded = kotlinx.serialization.json.Json.decodeFromString(MessageBodyPayload.serializer(), json)
assertEquals(100, decoded.tokens?.input)
assertEquals(50, decoded.tokens?.output)
assertEquals(1, decoded.content.size)
}
@Test
fun `decodeBodyPayload round-trip preserves tokens`() {
val tokens = TurnTokens(input = 250, output = 80)
val encoded = encodeBodyPayload(
content = listOf(Content.Text("ok")),
tokens = tokens,
)
val decoded = decodeBodyPayload(encoded)
assertEquals(250, decoded.tokens?.input)
assertEquals(80, decoded.tokens?.output)
}
private fun assistant(
msgId: String, convId: String, input: Int, output: Int, at: Instant,
) = MessageRecord.AssistantMessage(
id = msgId,
conversationId = convId,
content = listOf(Content.Text("reply")),
createdAt = at,
tokens = TurnTokens(input = input, output = output),
)
}