diff --git a/standalone/build.gradle.kts b/standalone/build.gradle.kts index 9cf0188..b56d0fa 100644 --- a/standalone/build.gradle.kts +++ b/standalone/build.gradle.kts @@ -1,10 +1,9 @@ @file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) +import org.gradle.api.artifacts.ConfigurationContainer import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar -import org.gradle.api.artifacts.ConfigurationContainer plugins { alias(libs.plugins.kotlin.multiplatform) @@ -19,16 +18,15 @@ val skipVectorMemory: Boolean = (project.findProperty("skipVectorMemory") == "true") || System.getenv("SKIP_VECTOR_MEMORY") == "1" +// :standalone — единственный entry-point проекта, JVM-only по факту (один +// `jvm()` target). Чтобы не таскать лишние sourceSet'ы, ВСЁ живёт в +// commonMain + commonTest — даже зависимости, которые формально JVM-only +// (ktor-server-cio, litert-openai, storage-ksqlite, ...). KMP-плагин тут +// только ради бесшовного потребления KMP-зависимостей (`:proto`, `:server`, +// `:journal-api`, ...); компилируется всё ровно в одну JVM-таргет. 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 { @@ -39,6 +37,7 @@ kotlin { sourceSets { commonMain.dependencies { + // Протокол + KMP storage API implementation(project(":proto")) implementation(project(":server")) implementation(project(":journal-api")) @@ -50,14 +49,6 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) - // litert-kmp: контракт (commonMain) - api(libs.litert.api) - - // litert-openai: JVM-реализация - implementation(libs.litert.openai) - } - - jvmMain.dependencies { // Парсер и загрузчик скилов (YAML frontmatter + markdown body). implementation(project(":skills")) @@ -67,24 +58,25 @@ kotlin { if (!skipVectorMemory) { implementation(project(":memory-vector")) } - implementation(project(":journal-api")) - implementation(project(":outbox-api")) - implementation(project(":context-api")) - implementation(project(":reflection-api")) + + // SQLite-бэкенд (ksqlite JNI) — JVM-only по факту. implementation(project(":storage-ksqlite")) - // Новый единый канал событий агента — заменил старые - // `agentEvents: MutableSharedFlow` и per-conv `ConversationEvents._flow`. - // Bounded tail + auto-TTL, generic CommonEvent envelope. + // Bounded-tail live event stream + per-event TTL. implementation(project(":outbox-inmemory")) + + // LLM-toolsets + generic LLM-side tools (LlmReflector, SkillMiner, + // LlmMemoryReviewer, ContextCompactor, парсеры/промпты). implementation(project(":agent-toolsets")) - // Generic LLM-side tools (LlmReflector, SkillMiner, LlmMemoryReviewer, - // ContextCompactor, парсеры/промпты). Вынесены из :standalone. implementation(project(":llm-tools")) + // Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter). - // Вынесен из :standalone — generic мост между MCP-SDK и LiteTool. implementation(project(":mcp-bridge")) + // litert-kmp: контракт (commonMain) + api(libs.litert.api) + // litert-openai: JVM-реализация + implementation(libs.litert.openai) // litert-google: встроенный LiteRT-LM движок, нужен только на runtime runtimeOnly(libs.litert.google) @@ -98,7 +90,7 @@ kotlin { implementation(libs.ktor.serialization.kotlinx.json) // Транспортные фасады (A2A остаётся заготовкой; на v1 не подключается в Main.kt — -// см. docs/ARCHITECTURE.md §3). + // см. docs/ARCHITECTURE.md §3). implementation(libs.a2a.server) // MCP (Model Context Protocol) клиент — подключение внешних/внутренних MCP-серверов @@ -106,7 +98,6 @@ kotlin { implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) - implementation(libs.ktor.serialization.kotlinx.json) // Логирование: kotlin-logging (тонкая обёртка slf4j-api) + logback-classic // (binding для JVM; без него slf4j-api NOP-логирует и не падает). @@ -115,11 +106,6 @@ kotlin { } commonTest.dependencies { - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlin.test) - } - - jvmTest.dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlin.test) @@ -161,11 +147,11 @@ val shadowJarTask = tasks.register("shadowJar") { // из любого типа, который сейчас прикидывается `configurations`. val cc = try { @Suppress("UNCHECKED_CAST") - configurations as org.gradle.api.artifacts.ConfigurationContainer + configurations as ConfigurationContainer } catch (_: ClassCastException) { // KGP-generated dependency accessor; обходим через raw project. @Suppress("UNCHECKED_CAST") - (project as org.gradle.api.Project).configurations as org.gradle.api.artifacts.ConfigurationContainer + (project as org.gradle.api.Project).configurations as ConfigurationContainer } from(cc.getByName("jvmRuntimeClasspath")) diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/A2aBridge.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/A2aBridge.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/A2aBridge.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/A2aBridge.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/Main.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundEvents.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundEvents.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundEvents.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundEvents.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundScheduler.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundScheduler.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundScheduler.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundScheduler.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ContextBuilder.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ContextBuilder.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ContextBuilder.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ContextBuilder.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationEvents.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationEvents.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationEvents.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationEvents.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationState.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationState.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationState.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ConversationState.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillTools.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillTools.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillTools.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillTools.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/Curator.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/Curator.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/Curator.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/Curator.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryToolsFactory.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryToolsFactory.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryToolsFactory.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryToolsFactory.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/GoogleLiteLlmJvm.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/llm/GoogleLiteLlmJvm.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/GoogleLiteLlmJvm.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/llm/GoogleLiteLlmJvm.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/LlmConfig.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/llm/LlmConfig.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/LlmConfig.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/llm/LlmConfig.kt diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/ModelDownloader.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/llm/ModelDownloader.kt similarity index 100% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/ModelDownloader.kt rename to standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/llm/ModelDownloader.kt diff --git a/standalone/src/jvmMain/resources/logback.xml b/standalone/src/jvmMain/resources/logback.xml deleted file mode 100644 index b213be7..0000000 --- a/standalone/src/jvmMain/resources/logback.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - %d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n - - - - - - - - - - - - - diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentReflectionTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentReflectionTest.kt deleted file mode 100644 index b7f146e..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentReflectionTest.kt +++ /dev/null @@ -1,74 +0,0 @@ -package pw.binom.agentik.standalone.agent - -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.time.Instant -import pw.binom.agentik.skills.SkillCatalog -import pw.binom.agentik.reflection.Reflection - -class ChatAgentReflectionTest { - - @Test - fun `buildSystemPrompt omits section when reflections empty`() { - val prompt = buildSystemPrompt( - base = "base", - skills = SkillCatalog.EMPTY, - memoryEnabled = false, - soulBody = null, - reflections = emptyList(), - ) - assertFalse(prompt.contains("Self-reflection")) - assertFalse(prompt.contains("слабые места")) - } - - @Test - fun `buildSystemPrompt includes section with weak spots when reflections non-empty`() { - val r = Reflection( - id = "r1", - conversationId = null, - createdAt = Instant.parse("2026-09-15T12:00:00Z"), - turnsAnalyzed = 10, - score = 2, - summary = "плохо", - weakSpots = listOf("медленно отвечаю на X", "путаю A и B"), - ) - val prompt = buildSystemPrompt( - base = "base", - skills = SkillCatalog.EMPTY, - memoryEnabled = false, - soulBody = null, - reflections = listOf(r), - ) - assertTrue(prompt.contains("Self-reflection")) - assertTrue(prompt.contains("слабые места")) - assertTrue(prompt.contains("медленно отвечаю на X")) - assertTrue(prompt.contains("путаю A и B")) - assertTrue(prompt.contains("score=2/5")) - } - - @Test - fun `buildSystemPrompt places reflection section after memory and before soul prepend`() { - val r = Reflection( - id = "r1", - conversationId = null, - createdAt = Instant.parse("2026-09-15T12:00:00Z"), - turnsAnalyzed = 5, - score = 4, - summary = "ok", - weakSpots = listOf("minor issue"), - ) - val prompt = buildSystemPrompt( - base = "base", - skills = SkillCatalog.EMPTY, - memoryEnabled = true, - soulBody = "I am a soul", - reflections = listOf(r), - ) - // soul первый, reflection последняя - val soulIdx = prompt.indexOf("I am a soul") - val reflIdx = prompt.indexOf("Self-reflection") - assertTrue(soulIdx >= 0 && reflIdx >= 0) - assertTrue(soulIdx < reflIdx, "soul должен идти перед reflection") - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt deleted file mode 100644 index 285a113..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt +++ /dev/null @@ -1,639 +0,0 @@ -package pw.binom.agentik.standalone.agent - -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runTest -import pw.binom.agentik.outbox.AgentEvent -import pw.binom.agentik.proto.Content -import pw.binom.agentik.outbox.Event as ProtoEvent -import pw.binom.agentik.skills.SkillCatalog -import pw.binom.agentik.skills.SkillFile -import pw.binom.agentik.standalone.llm.LlmBackend -import pw.binom.agentik.standalone.llm.LlmConfig -import pw.binom.agentik.journal.MessageRecord -import pw.binom.agentik.context.WorkingMemoryEntry -import pw.binom.agentik.storage.ksqlite.KsqliteStores -import pw.binom.litert.LiteContentPart -import pw.binom.litert.LiteConversation -import pw.binom.litert.LiteConversationConfig -import pw.binom.litert.LiteDelta -import pw.binom.litert.LiteLlm -import pw.binom.litert.LiteMessage -import pw.binom.litert.LiteRole -import pw.binom.litert.LiteTool -import pw.binom.litert.LiteToolCall -import pw.binom.agentik.standalone.llm.OpenAiConfig -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Instant -import pw.binom.agentik.toolsets.NamedTool - -class ChatAgentTest { - - private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores - private lateinit var fakeLlm: FakeLiteLlm - - @BeforeTest - fun setup() { - sqliteStores = KsqliteStores.inMemory("chat-${kotlin.random.Random.nextLong()}") - fakeLlm = FakeLiteLlm() - } - - @AfterTest - fun tearDown() { - sqliteStores.close() - } - - private fun newAgent( - sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores = this.sqliteStores, - llm: LiteLlm = this.fakeLlm, - tools: List = emptyList(), - skills: SkillCatalog = SkillCatalog.EMPTY, - ): ChatAgent = ChatAgent( - id = "agentik", - conversationStore = sqliteStores.conversations, - messageStore = sqliteStores.messages, - workingMemoryStore = sqliteStores.workingMemory, - reflectionStore = sqliteStores.reflections, - llm = llm, - llmConfig = LlmConfig( - backend = LlmBackend.OPENAI, - systemPrompt = "be brief", - openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), - ), - tools = tools, - skills = skills, - ) - - @Test - fun `createConversation does NOT seed system prompt into working memory`() = runTest { - // System prompt живёт ТОЛЬКО in-memory в ChatConversation.systemPrompt - // и едет в LLM через LiteConversationConfig.systemInstruction. В - // working_memory ничего не пишется — старт system prompt чистый, - // 0 entries. - val agent = newAgent() - val conv = agent.createConversation(temp = false) as ChatConversation - - val wm = sqliteStores.workingMemory.list(conv.id) - assertEquals(0, wm.size) - // System prompt виден через LiteConversationConfig, который LLM получит - // при первом send (см. `send passes system prompt and past history to LLM on first send`). - } - - @Test - fun `skills are appended to the system prompt passed to LLM`() = runTest { - // Skills добавляются в system prompt на лету при сборке ChatAgent. - // Проверяем это через то, что увидит LLM — systemInstruction в - // LiteConversationConfig (а не через working_memory, куда теперь - // ничего про system prompt не пишется). - val skills = SkillCatalog( - listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")), - ) - val agent = newAgent(skills = skills) - val conv = agent.createConversation(temp = false) - fakeLlm.reply = "ok" - conv.send(listOf(Content.Text("hi"))) - - val system = fakeLlm.lastConfig!!.systemInstruction - assertNotNull(system) - assertTrue("be brief" in system!!, "base prompt missing: $system") - assertTrue("## Навыки" in system, "skills section missing: $system") - assertTrue("lint" in system, "skill name missing: $system") - assertTrue("lint things" in system, "skill description missing: $system") - assertFalse("SECRET BODY" in system, "system prompt must not leak the skill body") - } - - @Test - fun `read_skill tool is registered when skills present`() = runTest { - val skills = SkillCatalog( - listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")), - ) - val agent = newAgent(skills = skills) - val conv = agent.createConversation(temp = false) - fakeLlm.reply = "ok" - conv.send(listOf(Content.Text("hi"))) - - val descriptors = fakeLlm.lastConfig!!.tools.map { it.describe() } - assertTrue(descriptors.any { SkillReadTool.NAME in it }, "expected read_skill tool: $descriptors") - } - - @Test - fun `no read_skill tool when skills absent`() = runTest { - val agent = newAgent() - val conv = agent.createConversation(temp = false) - fakeLlm.reply = "ok" - conv.send(listOf(Content.Text("hi"))) - - val descriptors = fakeLlm.lastConfig?.tools?.map { it.describe() } ?: emptyList() - assertTrue(descriptors.none { SkillReadTool.NAME in it }, "unexpected read_skill tool: $descriptors") - } - - @Test - fun `getConversation returns null for unknown id`() = runTest { - val agent = newAgent() - assertNull(agent.getConversation("nope")) - } - - @Test - fun `getConversations returns all stored persistent conversations`() = runTest { - val agent = newAgent() - agent.createConversation(temp = false) - agent.createConversation(temp = true) - val list = agent.getConversations(0, 10) - // temp-беседы не персистятся, в списке только persistent - assertEquals(1, list.size) - } - - @Test - fun `deleteConversation removes conversation and data`() = runTest { - val agent = newAgent() - val conv = agent.createConversation(temp = false) - val id = conv.id - - // добавим сообщение, чтобы потом убедиться, что каскад сработал - sqliteStores.messages.append( - pw.binom.agentik.journal.MessageRecord.UserMessage( - id = "m1", - conversationId = id, - content = listOf(pw.binom.agentik.journal.Content.Text("hi")), - createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000), - ), - ) - assertTrue(agent.deleteConversation(id)) - assertNull(agent.getConversation(id)) - assertNull(sqliteStores.conversations.get(id)) - assertEquals(emptyList(), sqliteStores.messages.listFlow(id, Instant.DISTANT_PAST).toList()) - } - - @Test - fun `deleteConversation returns false for unknown id`() = runTest { - val agent = newAgent() - assertEquals(false, agent.deleteConversation("nope")) - } - - @Test - fun `send emits start_reasoning, start_response, append_text, end`() = runTest { - val agent = newAgent() - fakeLlm.reply = "hello back" - val conv = agent.createConversation(temp = false) - - conv.send(listOf(Content.Text("hi"))) - - // user message записан в audit + working memory - val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList() - assertEquals(2, msgs.size) - assertEquals("hi", (msgs[0] as pw.binom.agentik.journal.MessageRecord.UserMessage).content.let { - (it[0] as pw.binom.agentik.journal.Content.Text).body - }) - assertEquals("hello back", (msgs[1] as pw.binom.agentik.journal.MessageRecord.AssistantMessage).content.let { - (it[0] as pw.binom.agentik.journal.Content.Text).body - }) - } - - @Test - fun `send reconstructs conversation history from working memory`() = runTest { - val agent = newAgent() - fakeLlm.rememberHistory = true - fakeLlm.reply = "first reply" - val conv1 = agent.createConversation(temp = false) - conv1.send(listOf(Content.Text("first user"))) - - // Новая беседа не должна видеть историю первой - val conv2 = agent.createConversation(temp = false) - fakeLlm.reply = "second reply" - conv2.send(listOf(Content.Text("second user"))) - - // В working_memory теперь НЕТ System-entries — только user + assistant. - // Системный промт живёт в ChatConversation.systemPrompt и едет в LLM - // через LiteConversationConfig.systemInstruction. - val wm1 = sqliteStores.workingMemory.list(conv1.id) - assertEquals(2, wm1.size) - val wm2 = sqliteStores.workingMemory.list(conv2.id) - assertEquals(2, wm2.size) - } - - @Test - fun `send passes system prompt and past history to LLM on first send`() = runTest { - val agent = newAgent() - fakeLlm.rememberHistory = true - val conv = agent.createConversation(temp = false) - fakeLlm.reply = "hi" - conv.send(listOf(Content.Text("hello"))) - - // Длинно-живущий LiteConversation: первый send создаёт его с systemInstruction - // и пустыми initialMessages (свежее user-сообщение пойдёт через sendStreamContents). - assertNotNull(fakeLlm.lastConfig) - assertEquals("be brief", fakeLlm.lastConfig!!.systemInstruction) - assertEquals(0, fakeLlm.lastConfig!!.initialMessages.size) - // Свежее user-сообщение отправлено через sendStreamContents - assertEquals(1, fakeLlm.conversations.size) - val sent = fakeLlm.lastContents - assertNotNull(sent) - assertEquals(1, sent.size) - assertEquals("hello", (sent[0] as LiteContentPart.Text).text) - } - - @Test - fun `multi-turn conversation accumulates history but recreates LiteConv each turn`() = runTest { - // Новая семантика (radical close+recreate после commit 7): - // каждый turn закрывает LiteConv и на следующем send() создаёт новую - // через getOrCreateLiteConversation, которая пересобирает initialMessages - // из working memory. То есть LiteConv — один на turn, не на диалог. - // Преимущество: interrupt можно сделать тривиально (close + cancelProcess), - // KV-cache жертвуем ради предсказуемости (~2s prefill на Gemma-4-E2B). - val agent = newAgent() - fakeLlm.rememberHistory = true - val conv = agent.createConversation(temp = false) - - fakeLlm.reply = "first reply" - conv.send(listOf(Content.Text("first user"))) - // первый turn: WM = [user, assistant] - assertEquals(2, sqliteStores.workingMemory.list(conv.id).size) - - fakeLlm.reply = "second reply" - conv.send(listOf(Content.Text("second user"))) - // второй turn: WM должен вырасти до [user, assistant, user, assistant] - val wm = sqliteStores.workingMemory.list(conv.id) - System.err.println("[TEST] wm.size=${wm.size}") - wm.forEachIndexed { i, row -> System.err.println("[TEST] $i: ${row.entry::class.simpleName} id=${row.id}") } - assertEquals(4, wm.size) - // Новая семантика: один LiteConv на turn → два LiteConv после двух send'ов. - assertEquals(2, fakeLlm.conversations.size) - // Второй LiteConv создан с initialMessages из working memory, ИСКЛЮЧАЯ pending user2 - // (он передаётся в sendStreamContents, чтобы не дублироваться). - val reopened = fakeLlm.conversations.last() - assertEquals(2, reopened.initialMessages.size) - assertEquals("first user", reopened.initialMessages[0].text) - assertEquals(LiteRole.USER, reopened.initialMessages[0].role) - assertEquals("first reply", reopened.initialMessages[1].text) - assertEquals(LiteRole.MODEL, reopened.initialMessages[1].role) - // После sendStreamContents (с user2 + сгенерированный asst2) mutableHistory = 4 - assertEquals(4, reopened.history.size) - assertEquals("second reply", reopened.history.last().text) - assertEquals(LiteRole.MODEL, reopened.history.last().role) - } - - @Test - fun `reloaded conversation reconstructs LiteConversation from working memory`() = runTest { - val agent = newAgent() - fakeLlm.rememberHistory = true - val conv = agent.createConversation(temp = false) - fakeLlm.reply = "first reply" - conv.send(listOf(Content.Text("first user"))) - val convId = conv.id - conv.close() - - // Открываем новое ChatConversation с тем же id — LiteConversation должен - // быть создан заново из working memory (первый user+assistant как initial). - val reopened = agent.getConversation(convId)!! - fakeLlm.reply = "second reply" - reopened.send(listOf(Content.Text("second user"))) - - val allConvs = fakeLlm.conversations - assertEquals(2, allConvs.size) // original + reopened - val reopenedLite = allConvs.last() - // Initial messages: только прошлые user+assistant (НЕ включая текущий "second user") - assertEquals(2, reopenedLite.initialMessages.size) - assertEquals("first user", reopenedLite.initialMessages[0].text) - assertEquals(LiteRole.USER, reopenedLite.initialMessages[0].role) - assertEquals("first reply", reopenedLite.initialMessages[1].text) - assertEquals(LiteRole.MODEL, reopenedLite.initialMessages[1].role) - } - - @Test - fun `LLM failure emits error event and persists error message`() = runTest { - val agent = newAgent() - fakeLlm.failMessage = "boom from llm" - val conv = agent.createConversation(temp = false) as ChatConversation - - val events = mutableListOf() - val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { - conv.events(Instant.DISTANT_PAST).collect { events.add(it) } - } - conv.send(listOf(Content.Text("hi"))) - delay(50) - job.cancel() - - 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.listFlow(conv.id, Instant.DISTANT_PAST).toList() - assertEquals(2, msgs.size) - assertIs(msgs[0]) - val err = assertIs(msgs[1]) - assertEquals("boom from llm", err.message) - - // backfill через getMessages (polling/reconnect) тоже видит ошибку - val proto = conv.getMessages(Instant.DISTANT_PAST, offset = 0, limit = 10) - assertTrue( - proto.any { it is pw.binom.agentik.proto.Message.Error && it.message == "boom from llm" }, - "history=$proto", - ) - } - - @Test - fun `interrupt mid-slow-stream preserves user message and no assistant`() = runTest { - // Новая семантика interrupt (commit 7): ставится флаг, LiteConv.cancel() - // бросает CancellationException в стриме, runTurn выходит через finally. - // Если turn не успел ничего сгенерить (reply.isEmpty() && toolExchanges.isEmpty()) - // — AssistantMessage в audit log НЕ пишется. Только user + End/Interrupted. - val agent = newAgent() - fakeLlm.slow = true - val conv = agent.createConversation(temp = false) - - // Подписываемся на events ДО send() — SharedFlow без replay, после - // отправки событий подписка ничего не увидит. - val events = mutableListOf() - val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { - conv.events(Instant.DISTANT_PAST).collect { events.add(it) } - } - - val sendJob = launch { - try { - conv.send(listOf(Content.Text("hi"))) - } catch (_: kotlinx.coroutines.CancellationException) { - // ok - } - } - // ждём, пока корутина дойдёт до sendStreamContents и повиснет на slow-эмиссии - delay(200) - conv.interrupt() - sendJob.join() - eventsJob.cancel() - - // audit: только user (assistant не успел сгенериться) - val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList() - assertEquals(1, msgs.size) - assertIs(msgs[0]) - - // working memory: только user (assistant skipped because пустой) - val wm = sqliteStores.workingMemory.list(conv.id) - assertEquals(1, wm.size) - assertTrue(wm[0].entry is WorkingMemoryEntry.User) - - // events: должны включать Interrupted + End - assertTrue(events.any { it is ProtoEvent.Interrupted }, "events=$events") - assertTrue(events.any { it is ProtoEvent.End }, "events=$events") - } - - @Test - fun `interrupt after tool execution preserves tool result in working memory`() = runTest { - // Сценарий "LLM вызвал тул, инструмент выполнился, потом interrupt()": - // 1. LLM скриптован на ToolCalls([echo_tool]) - // 2. Tool реально вызывается через toolsetDispatch.dispatch() - // 3. interrupt() приходит в окне между финальным text и завершением turn'а - // - // В audit log: user + ToolCall + ToolResult (инструмент выполнился). - // В working_memory: user + ToolExchange(result=echo output, wasCancelled=false). - // В events: ToolCall + ToolResult + Interrupted + End. - val agent = newAgent() - val conv = agent.createConversation(temp = false) - - // LLM скриптован: tool call. - fakeLlm.scriptedReplies = mutableListOf( - FakeLiteLlm.Reply.ToolCalls(listOf("echo_tool" to mapOf("q" to "hi"))), - ) - - val echoTool = object : LiteTool { - override fun describe(): String = """{"name":"echo_tool","description":"echoes args"}""" - override fun invoke(arguments: String): String = """{"echo":$arguments}""" - } - agent.registerToolForTest("echo_tool", echoTool) - - // Подписываемся ДО send — SharedFlow без replay - val events = mutableListOf() - val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { - conv.events(Instant.DISTANT_PAST).collect { events.add(it) } - } - - val sendJob = launch { - try { - conv.send(listOf(Content.Text("run echo tool"))) - } catch (_: kotlinx.coroutines.CancellationException) {} - } - // Ждём пока инструмент выполнится (turn завершится нормально) - sendJob.join() - // interrupt() ПОСЛЕ завершения turn — не должно ничего менять в БД, - // но проверяем что events включает все ожидаемые типы. - conv.interrupt() - eventsJob.cancel() - - // audit: user + toolcall + toolresult (tool выполнился), assistant может быть - val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList() - val toolResult = msgs.filterIsInstance().firstOrNull() - assertNotNull(toolResult, "tool result должен быть в audit — tool выполнился нормально") - val toolResultResult = toolResult!!.result!! - assertTrue(toolResultResult.contains("echo"), "tool result содержит реальный ответ тулы: $toolResultResult") - - // working memory: user + tool_exchange - val wm = sqliteStores.workingMemory.list(conv.id) - val exchanges = wm.mapNotNull { (it.entry as? WorkingMemoryEntry.ToolExchange) } - assertEquals(1, exchanges.size) - assertEquals("echo_tool", exchanges[0].toolName) - assertFalse(exchanges[0].wasCancelled, "tool реально выполнился, не был отменён") - assertTrue(exchanges[0].resultText.contains("echo")) - - // events должны включать ToolCall + ToolResult. End — обязательно (turn завершился). - assertTrue(events.any { it is ProtoEvent.ToolCall }, "events=$events") - assertTrue(events.any { it is ProtoEvent.ToolResult }, "events=$events") - assertTrue(events.any { it is ProtoEvent.End }, "events=$events") - } - - @Test - fun `temp conversation is not persisted across agent instances`() = runTest { - // Поднимаем file-backed БД, создаём temp-беседу - sqliteStores.close() - val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db") - sqliteStores = KsqliteStores.open(dbPath) - val agent1 = ChatAgent( - id = "agentik", - conversationStore = sqliteStores.conversations, - messageStore = sqliteStores.messages, - workingMemoryStore = sqliteStores.workingMemory, - reflectionStore = sqliteStores.reflections, - llm = FakeLiteLlm().also { fakeLlm = it }, - llmConfig = LlmConfig( - backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI, - systemPrompt = "be brief", - openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), - ), - ) - val tempConv = agent1.createConversation(temp = true) - val tempId = tempConv.id - assertNotNull(agent1.getConversation(tempId)) - - // Переоткрываем БД — temp-беседа не должна пережить рестарт - sqliteStores.close() - sqliteStores = KsqliteStores.open(dbPath) - val agent2 = ChatAgent( - id = "agentik", - conversationStore = sqliteStores.conversations, - messageStore = sqliteStores.messages, - workingMemoryStore = sqliteStores.workingMemory, - reflectionStore = sqliteStores.reflections, - llm = fakeLlm, - llmConfig = LlmConfig( - backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI, - systemPrompt = "be brief", - openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), - ), - ) - assertNull(agent2.getConversation(tempId)) - java.io.File(dbPath).delete() - } - - @Test - fun `non-temp conversation persists across agent instances`() = runTest { - sqliteStores.close() - val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db") - sqliteStores = KsqliteStores.open(dbPath) - val agent1 = ChatAgent( - id = "agentik", - conversationStore = sqliteStores.conversations, - messageStore = sqliteStores.messages, - workingMemoryStore = sqliteStores.workingMemory, - reflectionStore = sqliteStores.reflections, - llm = FakeLiteLlm().also { fakeLlm = it }, - llmConfig = LlmConfig( - backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI, - systemPrompt = "be brief", - openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), - ), - ) - val conv = agent1.createConversation(temp = false) - val id = conv.id - - sqliteStores.close() - sqliteStores = KsqliteStores.open(dbPath) - val agent2 = ChatAgent( - id = "agentik", - conversationStore = sqliteStores.conversations, - messageStore = sqliteStores.messages, - workingMemoryStore = sqliteStores.workingMemory, - reflectionStore = sqliteStores.reflections, - llm = fakeLlm, - llmConfig = LlmConfig( - backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI, - systemPrompt = "be brief", - openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), - ), - ) - assertNotNull(agent2.getConversation(id)) - java.io.File(dbPath).delete() - } - - private fun fakeLiteLlmForReload(): LiteLlm = object : LiteLlm { - override val backendName: String = "fake" - override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null) - override fun isInitialized(): Boolean = true - override fun createConversation(config: LiteConversationConfig): LiteConversation = - error("not used in reload test") - override fun infer(request: pw.binom.litert.LiteRequest): String = error("not used") - override fun inferStream(request: pw.binom.litert.LiteRequest): Flow = error("not used") - override fun close() {} - } - - @Test - fun `tool-call loop executes registered tool and feeds result back`() = runTest { - val echoTool = object : LiteTool { - override fun describe(): String = """{"type":"function","function":{"name":"echo"}}""" - override fun invoke(arguments: String): String = "echoed: $arguments" - } - val toolLlm = ToolLoopFakeLiteLlm() - val agent = newAgent(llm = toolLlm, tools = listOf(NamedTool("echo", echoTool))) - val conv = agent.createConversation(temp = false) - - conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool"))) - - // sendStreamContents вызывается дважды: первый раз с user-сообщением - // (LLM отвечает tool_call), второй раз — после addToolResult — для - // триггера continuation у stateless-бэкендов (OpenAI). На этой fake - // LiteLlm оба попадают в счётчик. - assertEquals(2, toolLlm.toolCallCount, "expected user send + post-tool continuation") - assertEquals("echoed: {\"x\":\"hi\"}", toolLlm.lastToolResult, - "expected echo tool invoked with the LLM's args, result fed back via addToolResult") - assertEquals("final reply", toolLlm.finalReplyEmitted, - "expected continuation send after tool result to emit final text") - - agent.close() - } - - @Test - fun `agentEvents - Created + Deleted flow`() = runTest { - val agent = newAgent() - val events = mutableListOf() - val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { - // Agent.events() удалён из :proto — события живут в - // agent.outbox.agentEvents(): Flow; - // распаковываем .event для получения AgentEvent. - agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { events.add(it.event) } - } - val conv = agent.createConversation(temp = false) - agent.deleteConversation(conv.id) - delay(50) - job.cancel() - - assertEquals(2, events.size) - val created = events[0] as AgentEvent.Created - val deleted = events[1] as AgentEvent.Deleted - assertEquals(conv.id, created.conversationId) - assertEquals(conv.id, deleted.id) - } -} - -/** Поддельный LiteLlm: возвращает fakeLlm.reply в sendStreamContents, опционально запоминает history. */ -private class ToolLoopFakeLiteLlm : LiteLlm { - override val backendName: String = "fake-tool" - override val capabilities: pw.binom.litert.LiteCapabilities? = null - - var toolCallCount: Int = 0 - var lastToolResult: String? = null - var finalReplyEmitted: String? = null - - override fun isInitialized(): Boolean = true - - override fun createConversation(config: LiteConversationConfig): LiteConversation { - return object : LiteConversation { - private val hist = mutableListOf() - override val history: List get() = hist.toList() - override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt))) - override fun sendStreamContents(contents: List): Flow { - hist.add(LiteMessage(LiteRole.USER, contents)) - toolCallCount++ - return flowOf( - LiteDelta( - text = "", - isDone = true, - toolCalls = listOf(LiteToolCall(name = "echo", arguments = mapOf("x" to "hi"))), - ), - ) - } - override fun send(prompt: String): String = "unused" - override fun sendContents(contents: List): String = "unused" - override fun cancel() {} - override fun tokenCount(): Int = hist.size - override fun addToolResult(callId: String?, name: String, result: String): LiteDelta { - lastToolResult = result - val reply = "final reply" - finalReplyEmitted = reply - return LiteDelta(text = reply, isDone = true) - } - override fun close() {} - } - } - - override fun infer(request: pw.binom.litert.LiteRequest): String = error("not used") - override fun inferStream(request: pw.binom.litert.LiteRequest): Flow = error("not used") - override fun close() {} -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt deleted file mode 100644 index 6121b6c..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt +++ /dev/null @@ -1,200 +0,0 @@ -package pw.binom.agentik.standalone.agent - -import kotlinx.coroutines.runBlocking -import pw.binom.agentik.standalone.llm.LlmConfig -import pw.binom.agentik.storage.ksqlite.KsqliteStores -import pw.binom.agentik.toolsets.ToolsetContribution -import pw.binom.litert.LiteLlm -import pw.binom.litert.LiteTool -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * Интеграционные тесты ChatAgent + toolsets: проверяем что при пустом - * toolsets=List (дефолт) агент ведёт себя как раньше (нет enable/disable тулов, - * нет секции в system prompt), а при non-empty — добавляет их и регистрирует - * диспетчер. - */ -class ChatAgentToolsetsTest { - - private fun stubTool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool { - override fun describe() = """{"name":"$name","description":"stub","parameters":{"type":"object","properties":{}}}""" - override fun invoke(arguments: String) = response - } - - private fun stubLlm(): LiteLlm = object : LiteLlm { - override val backendName: String = "stub" - override fun isInitialized(): Boolean = true - override fun createConversation(config: pw.binom.litert.LiteConversationConfig): pw.binom.litert.LiteConversation = - throw UnsupportedOperationException("not used in this test") - override fun infer(request: pw.binom.litert.LiteRequest): String = - throw UnsupportedOperationException("not used in this test") - override fun inferStream(request: pw.binom.litert.LiteRequest): kotlinx.coroutines.flow.Flow = - throw UnsupportedOperationException("not used in this test") - override val capabilities: pw.binom.litert.LiteCapabilities? = null - override fun close() {} - } - - private fun newAgent( - toolsets: List = emptyList(), - ): Pair { - val sqliteStores = KsqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}") - val agent = ChatAgent( - id = "test-agent", - conversationStore = sqliteStores.conversations, - messageStore = sqliteStores.messages, - workingMemoryStore = sqliteStores.workingMemory, - reflectionStore = sqliteStores.reflections, - llm = stubLlm(), - llmConfig = LlmConfig( - backend = pw.binom.agentik.standalone.llm.LlmBackend.GOOGLE, - systemPrompt = "base", - google = pw.binom.agentik.standalone.llm.GoogleConfig(modelPath = "/tmp/fake.gguf"), - ), - toolsets = toolsets, - ) - return agent to sqliteStores - } - - @Test - fun `default (empty toolsets) does not register enable or disable tools`() { - val (agent, storage) = newAgent() - try { - // Проверяем через allTools-эквивалент: вызываем enable_toolset - // и ожидаем что он не найден — это значит тул не зарегистрирован. - val conv = agent.createConversation(temp = true) - // system prompt не должен содержать секции Toolsets - val prompt = (conv as ChatConversation).let { it.systemPromptForTest() } - assertFalse(prompt.contains("## Toolsets"), "toolsets section must NOT appear when toolsets empty") - assertFalse(prompt.contains("enable_toolset"), "enable_toolset must NOT be mentioned when toolsets empty") - } finally { storage.close() } - } - - @Test - fun `non-empty toolsets registers enable and disable tools and includes section`() { - val toolsets = listOf( - ToolsetContribution( - name = "media", - description = "image and video processing", - tools = listOf(ToolsetContribution.ToolEntry("resize_image", stubTool("resize_image"))), - ), - ToolsetContribution( - name = "web", - description = "fetch and parse web pages", - tools = listOf(ToolsetContribution.ToolEntry("fetch_url", stubTool("fetch_url"))), - ), - ) - val (agent, storage) = newAgent(toolsets = toolsets) - try { - val conv = agent.createConversation(temp = true) as ChatConversation - val prompt = conv.systemPromptForTest() - assertTrue(prompt.contains("## Toolsets"), "toolsets section MUST appear when toolsets non-empty") - assertTrue(prompt.contains("- media — image and video processing")) - assertTrue(prompt.contains("- web — fetch and parse web pages")) - // Оба тула — в списке allTools (через system prompt проверяем только prompt; - // наличие тулов проверим отдельно — см. ниже). - } finally { storage.close() } - } - - @Test - fun `non-empty toolsets - enable_toolset activates and dispatches auto-included tools`() { - val mediaTool = stubTool("resize_image", "image-resized-100x100") - val toolsets = listOf( - ToolsetContribution( - name = "media", - description = "image and video processing", - tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)), - ), - ) - val (agent, storage) = newAgent(toolsets = toolsets) - try { - val conv = agent.createConversation(temp = true) as ChatConversation - - // Вызываем enable_toolset через диспетчер (минуя LLM, напрямую) - val enabled = runBlocking { - conv.dispatchToolForTest("enable_toolset", """{"name":"media"}""") - } - assertEquals("Toolset 'media' activated.", enabled) - - // Теперь resize_image должен работать (тулсет активен) - val resized = runBlocking { - conv.dispatchToolForTest("resize_image", "{}") - } - assertEquals("image-resized-100x100", resized) - } finally { storage.close() } - } - - @Test - fun `non-empty toolsets - calling tool from inactive toolset triggers auto-activation`() { - val mediaTool = stubTool("resize_image", "auto-activated-and-ran") - val toolsets = listOf( - ToolsetContribution( - name = "media", - description = "x", - tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)), - ), - ) - val (agent, storage) = newAgent(toolsets = toolsets) - try { - val conv = agent.createConversation(temp = true) as ChatConversation - - // Без enable сразу вызываем resize_image — диспетчер должен auto-activate. - val result = runBlocking { - conv.dispatchToolForTest("resize_image", "{}") - } - assertEquals("auto-activated-and-ran", result) - } finally { storage.close() } - } - - @Test - fun `non-empty toolsets - disable_toolset removes from active list`() { - // Тест проверяет только сайд-эффект на реестре (active set), - // а не реальный dispatch — потому что диспетчер auto-activate'ит тулсет - // обратно при следующем вызове (это by design: если модель забыла что - // тулсет выключен, мы прощаем и включаем заново). - val toolsets = listOf( - ToolsetContribution( - name = "media", - description = "x", - tools = listOf(ToolsetContribution.ToolEntry("resize_image", stubTool("resize_image"))), - ), - ) - val (agent, storage) = newAgent(toolsets = toolsets) - try { - val conv = agent.createConversation(temp = true) as ChatConversation - - runBlocking { conv.dispatchToolForTest("enable_toolset", """{"name":"media"}""") } - // disable - val disabled = runBlocking { - conv.dispatchToolForTest("disable_toolset", """{"name":"media"}""") - } - assertEquals("Toolset 'media' deactivated.", disabled) - // После disable реестр уже не содержит media в active - // (проверяем косвенно: disable повторно всё ещё возвращает тот же uniform message) - val disabledAgain = runBlocking { - conv.dispatchToolForTest("disable_toolset", """{"name":"media"}""") - } - assertEquals("Toolset 'media' deactivated.", disabledAgain) - } finally { storage.close() } - } -} - -// Вспомогательные extension'ы — открываем systemPrompt/dispatch наружу для тестов. -internal fun ChatConversation.systemPromptForTest(): String { - // Через рефлексию достаём private val systemPrompt. - val f = this::class.java.getDeclaredField("systemPrompt").apply { isAccessible = true } - return f.get(this) as String -} - -internal suspend fun ChatConversation.dispatchToolForTest(toolName: String, argsJson: String): String { - // Через toolsetDispatch (если есть) или прямой toolsByName. - val dispatchField = this::class.java.declaredFields.first { it.name == "toolsetDispatch" }.apply { isAccessible = true } - val dispatch = dispatchField.get(this) ?: error("toolsetDispatch must be set when toolsets present") - val outcome = (dispatch as pw.binom.agentik.toolsets.ToolsetDispatchPolicy).dispatch(toolName, argsJson) - return when (outcome) { - is pw.binom.agentik.toolsets.ToolsetDispatchPolicy.Outcome.Ran -> outcome.result - is pw.binom.agentik.toolsets.ToolsetDispatchPolicy.Outcome.Unknown -> "[tool not found: $toolName]" - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt deleted file mode 100644 index 70b63a3..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt +++ /dev/null @@ -1,186 +0,0 @@ -package pw.binom.agentik.standalone.agent - -import kotlinx.coroutines.test.runTest -import pw.binom.agentik.memory.MemoryCategory -import pw.binom.agentik.memory.MemoryNote -import pw.binom.agentik.memory.MemorySource -import pw.binom.agentik.memory.MemoryStore -import pw.binom.agentik.memory.NewMemoryNote -import pw.binom.agentik.memory.ReviewedTurn -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.ksqlite.KsqliteStores -import pw.binom.agentik.proto.Content as ProtoContent -import pw.binom.litert.LiteConversation -import pw.binom.litert.LiteConversationConfig -import pw.binom.litert.LiteLlm -import pw.binom.litert.LiteMessage -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.Flow -import pw.binom.agentik.memory.MemoryStoreEvent -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlin.test.assertFalse -import kotlin.time.Instant -import kotlinx.coroutines.flow.asSharedFlow -import pw.binom.agentik.llm.tools.ContextCompactor -import pw.binom.agentik.llm.tools.SummaryTurn - -/** - * Тесты для [ChatConversation.compactPreTurnIfNeeded]: триггер compaction'а - * при превышении порога, вызов суммаризатора, триггер memory review, и - * атомарный replace в working memory. - */ -class CompactionTest { - - private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores - private lateinit var fakeLlm: FakeLiteLlm - - @BeforeTest - fun setup() { - sqliteStores = KsqliteStores.inMemory("compact-${kotlin.random.Random.nextLong()}") - fakeLlm = FakeLiteLlm() - } - - @AfterTest - fun tearDown() { - sqliteStores.close() - } - - private fun newAgent( - contextWindow: Int? = null, - compressionThreshold: Double = 0.8, - compactor: ContextCompactor? = null, - memoryStore: MemoryStore? = null, - ): ChatAgent { - val reviewer = if (memoryStore != null) KeywordMdReviewer() else null - return ChatAgent( - id = "test", - conversationStore = sqliteStores.conversations, - messageStore = sqliteStores.messages, - workingMemoryStore = sqliteStores.workingMemory, - reflectionStore = sqliteStores.reflections, - llm = fakeLlm, - llmConfig = LlmConfig( - backend = LlmBackend.OPENAI, - systemPrompt = "be brief", - openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), - ), - memoryStore = memoryStore, - memoryReviewer = reviewer, - contextWindow = contextWindow, - compressionThreshold = compressionThreshold, - contextCompactor = compactor, - ) - } - - @Test - fun `compaction is no-op when contextWindow is null`() = runTest { - // contextWindow=null → даже с огромной историей compaction не запустится. - fakeLlm.reply = "hi" - val agent = newAgent(contextWindow = null, compactor = RecordingCompactor("summary")) - val conv = agent.createConversation(temp = false) as ChatConversation - repeat(10) { - conv.send(listOf(ProtoContent.Text("turn $it: ${"x".repeat(200)}"))) - } - val wm = sqliteStores.workingMemory.list(conv.id) - // Без compaction все ходы остаются в памяти (System + 10 user/assistant = 21 строк). - val summaries = wm.filter { it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Summary } - assertEquals(0, summaries.size, "compaction must not run without contextWindow") - } - - @Test - fun `compaction is no-op when compactor is null but window is set`() = runTest { - fakeLlm.reply = "hi" - val agent = newAgent(contextWindow = 10, compactor = null) - val conv = agent.createConversation(temp = false) as ChatConversation - conv.send(listOf(ProtoContent.Text("first"))) - val wm = sqliteStores.workingMemory.list(conv.id) - // System + User + Assistant = 3. Без compactor — никаких Summary. - val summaries = wm.filter { it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Summary } - assertEquals(0, summaries.size, "no compaction runs without compactor") - } - - @Test - fun `compaction triggers when estimated tokens exceed threshold`() = runTest { - fakeLlm.reply = "ok" - val compactor = RecordingCompactor("**Goal**: x\n**Active**: y\n**Resolved**: z") - // contextWindow = 20 chars → ~5 токенов. С порогом 0.5 (50%) — почти любой ход пробивает. - val agent = newAgent(contextWindow = 20, compressionThreshold = 0.5, compactor = compactor) - val conv = agent.createConversation(temp = false) as ChatConversation - - conv.send(listOf(ProtoContent.Text("user message one — long enough to cross threshold"))) - - // Compactor должен был быть вызван хотя бы раз. - assertTrue(compactor.calls > 0, "compactor must be called at least once when above threshold") - // В working memory должна появиться Summary. - val wm = sqliteStores.workingMemory.list(conv.id) - val summaries = wm.filter { it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Summary } - assertTrue(summaries.isNotEmpty(), "at least one Summary entry should be present after compaction") - // Summary-текст — то, что вернул наш compactor. - val summaryText = (summaries.first().entry as pw.binom.agentik.context.WorkingMemoryEntry.Summary).text - assertTrue(summaryText.startsWith("**Goal**"), "summary text should come from compactor: $summaryText") - } - - @Test - fun `compaction calls memoryReviewer reviewPreCompaction`() = runTest { - fakeLlm.reply = "ok" - val memStore = TestInMemoryMemoryStore() - val compactor = RecordingCompactor("compacted summary") - val agent = newAgent( - contextWindow = 30, - compressionThreshold = 0.5, - compactor = compactor, - memoryStore = memStore, - ) - val conv = agent.createConversation(temp = false) as ChatConversation - - conv.send(listOf(ProtoContent.Text("Я обычно предпочитаю kotlin для бэкенда."))) - - // Память должна получить хотя бы одну заметку от reviewPreCompaction. - val notes = memStore.list() - assertTrue(notes.any { it.category == MemoryCategory.PREFERENCE && it.content.contains("kotlin") }, - "memory should capture a preference fact before compaction drops the turn") - } - - @Test - fun `compaction preserves recent turns (KEEP_RECENT_TURNS)`() = runTest { - fakeLlm.reply = "ok" - val compactor = RecordingCompactor("compacted summary") - val agent = newAgent(contextWindow = 30, compressionThreshold = 0.3, compactor = compactor) - val conv = agent.createConversation(temp = false) as ChatConversation - - conv.send(listOf(ProtoContent.Text("first turn"))) - conv.send(listOf(ProtoContent.Text("second turn"))) - conv.send(listOf(ProtoContent.Text("third turn — long content ${"y".repeat(150)}"))) - - val wm = sqliteStores.workingMemory.list(conv.id) - // Должны быть: System + хотя бы один Summary + последние KEEP_RECENT_TURNS ходов. - // KEEP_RECENT_TURNS = 4 → user/assistant последних двух ходов (third + second) могут быть не тронуты. - val userAssistantCount = wm.count { - it.entry is pw.binom.agentik.context.WorkingMemoryEntry.User || - it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Assistant - } - // Минимум 1 ход остаётся (KEEP_RECENT_TURNS). - assertTrue(userAssistantCount >= 1, "at least one recent turn must be preserved") - } -} - -private class RecordingCompactor(private val result: String) : ContextCompactor { - var calls = 0 - override suspend fun summarize(turns: List): String { - calls++ - return result - } -} - -/** - * (InMemoryMemoryStore вынесен в [TestInMemoryMemoryStore].) - */ diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ContextPrefixTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ContextPrefixTest.kt deleted file mode 100644 index 95ceea2..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ContextPrefixTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package pw.binom.agentik.standalone.agent - -import pw.binom.agentik.journal.MessageContext -import pw.binom.agentik.journal.MessageOrigin.EVENT -import pw.binom.agentik.journal.MessageOrigin.SYSTEM -import pw.binom.agentik.journal.MessageOrigin.USER -import pw.binom.litert.LiteContentPart -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNull - -/** - * Тесты для префикса контекста инициации хода в user-сообщениях. - * Только для не-USER origin'ов. USER — без изменений. - */ -class ContextPrefixTest { - - @Test - fun `USER origin produces no prefix`() { - val ctx = MessageContext(origin = USER) - val parts = listOf(LiteContentPart.Text("hello")) - val out = applyContextPrefix(parts, ctx) - assertEquals(parts, out, "USER should not modify content") - } - - @Test - fun `null context produces no prefix`() { - val parts = listOf(LiteContentPart.Text("hello")) - val out = applyContextPrefix(parts, null) - assertEquals(parts, out) - } - - @Test - fun `SYSTEM origin prepends label to first text part`() { - val ctx = MessageContext(origin = SYSTEM, description = "agent startup greeting") - val parts = listOf(LiteContentPart.Text("boot")) - val out = applyContextPrefix(parts, ctx) - assertEquals(1, out.size) - val text = assertIs(out[0]) - assertEquals("[SYSTEM] agent startup greeting\nboot", text.text) - } - - @Test - fun `EVENT origin with sourceId includes it`() { - val ctx = MessageContext( - origin = EVENT, - description = "scheduled cron morning-briefing", - sourceId = "cron-42", - ) - val parts = listOf(LiteContentPart.Text("wake up")) - val out = applyContextPrefix(parts, ctx) - val text = assertIs(out[0]) - assertEquals("[EVENT] scheduled cron morning-briefing (sourceId=cron-42)\nwake up", text.text) - } - - @Test - fun `prefix only added to first text part, others untouched`() { - val ctx = MessageContext(origin = EVENT, description = "test") - val parts = listOf( - LiteContentPart.Text("first"), - LiteContentPart.Text("second"), - ) - val out = applyContextPrefix(parts, ctx) - assertEquals(2, out.size) - val first = assertIs(out[0]) - val second = assertIs(out[1]) - assertEquals("[EVENT] test\nfirst", first.text) - assertEquals("second", second.text) - } - - @Test - fun `prefix with no text parts is prepended as standalone text`() { - val ctx = MessageContext(origin = SYSTEM, description = "ping") - // Симулируем: модель получает картинку + контекст — контекст идёт первой Text-частью. - val parts = listOf(LiteContentPart.Text("just prefix")) - val out = applyContextPrefix(parts, ctx) - assertEquals(1, out.size) - val text = assertIs(out[0]) - assertEquals("[SYSTEM] ping\njust prefix", text.text) - } - - @Test - fun `formatContextPrefix formats name + description + sourceId`() { - val ctx = MessageContext(origin = EVENT, description = "wake", sourceId = "cron-1") - assertEquals("[EVENT] wake (sourceId=cron-1)", formatContextPrefix(ctx)) - } - - @Test - fun `formatContextPrefix omits blank description and sourceId`() { - val ctx = MessageContext(origin = SYSTEM) - assertEquals("[SYSTEM]", formatContextPrefix(ctx)) - } - - @Test - fun `formatContextPrefix omits blank sourceId even if description is set`() { - val ctx = MessageContext(origin = SYSTEM, description = "boot", sourceId = "") - assertEquals("[SYSTEM] boot", formatContextPrefix(ctx)) - } - - @Test - fun `USER origin with context fields still produces no prefix`() { - // Контекст с USER-происхождением, но с заполненным description/sourceId: - // не должен триггерить префикс (UI-метаданные для логирования). - val ctx = MessageContext(origin = USER, sourceId = "irc:agentik", description = "PRIVMSG") - val parts = listOf(LiteContentPart.Text("hi")) - val out = applyContextPrefix(parts, ctx) - assertEquals(parts, out) - } - - // Вспомогательное для теста - @Test - fun `null-context assert helper`() { - assertNull(null as String?) - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt deleted file mode 100644 index 6267b00..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt +++ /dev/null @@ -1,120 +0,0 @@ -package pw.binom.agentik.standalone.agent - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import pw.binom.litert.LiteContentPart -import pw.binom.litert.LiteConversation -import pw.binom.litert.LiteConversationConfig -import pw.binom.litert.LiteDelta -import pw.binom.litert.LiteLlm -import pw.binom.litert.LiteMessage -import pw.binom.litert.LiteRole -import pw.binom.litert.LiteToolCall - -/** - * Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая - * заданной строкой [reply] двумя фрагментами + done. - */ -internal class FakeLiteLlm : LiteLlm { - sealed class Reply { - data class Text(val text: String) : Reply() - data class ToolCalls(val calls: List>>) : Reply() - } - - override val backendName: String = "fake" - override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null) - var reply: String = "" - var rememberHistory: Boolean = false - var slow: Boolean = false - var failMessage: String? = null - - /** - * Если задан, LLM проходит по этому списку ответов по порядку: первый - * sendStreamContents → первый Reply, второй → второй и т.д. Если список - * кончился — fallback на [reply] (text). - */ - var scriptedReplies: MutableList = mutableListOf() - - var lastConfig: LiteConversationConfig? = null - var lastContents: List? = null - val conversations = mutableListOf() - - override fun isInitialized(): Boolean = true - - override fun createConversation(config: LiteConversationConfig): LiteConversation { - lastConfig = config - val conv = FakeLiteConversation(this, config) - conversations.add(conv) - return conv - } - - override fun infer(request: pw.binom.litert.LiteRequest): String = - throw UnsupportedOperationException("not used in test") - - override fun inferStream(request: pw.binom.litert.LiteRequest): Flow = - throw UnsupportedOperationException("not used in test") - - override fun close() {} - - fun nextReply(): Reply = - if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply) -} - -internal class FakeLiteConversation( - private val parent: FakeLiteLlm, - config: LiteConversationConfig, -) : LiteConversation { - val initialMessages: List = config.initialMessages - private val mutableHistory: MutableList = config.initialMessages.toMutableList() - override val history: List get() = mutableHistory.toList() - - override fun sendStream(prompt: String): Flow = - sendStreamContents(listOf(LiteContentPart.Text(prompt))) - - override fun sendStreamContents(contents: List): Flow { - parent.lastContents = contents - parent.failMessage?.let { msg -> - return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) } - } - mutableHistory.add(LiteMessage(LiteRole.USER, contents)) - val next = parent.nextReply() - return when (next) { - is FakeLiteLlm.Reply.Text -> { - if (parent.slow) { - kotlinx.coroutines.flow.flow { - emit(LiteDelta(text = next.text.substring(0, next.text.length / 2))) - kotlinx.coroutines.delay(10_000) - emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true)) - mutableHistory.add(LiteMessage.model(next.text)) - } - } else { - val first = next.text.substring(0, next.text.length / 2) - val second = next.text.substring(next.text.length / 2) - flowOf( - LiteDelta(text = first), - LiteDelta(text = second, isDone = true), - ).also { mutableHistory.add(LiteMessage.model(next.text)) } - } - } - is FakeLiteLlm.Reply.ToolCalls -> { - val calls = next.calls.map { (name, args) -> - LiteToolCall(name = name, arguments = args) - } - flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true)) - } - } - } - override fun send(prompt: String): String { - parent.lastContents = listOf(LiteContentPart.Text(prompt)) - return parent.reply - } - override fun sendContents(contents: List): String { - parent.lastContents = contents - return parent.reply - } - override fun cancel() {} - override fun tokenCount(): Int = history.size - override fun addToolResult(callId: String?, name: String, result: String): LiteDelta = - LiteDelta(text = "", isDone = true) - override fun close() {} -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt deleted file mode 100644 index 680bdbb..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt +++ /dev/null @@ -1,348 +0,0 @@ -package pw.binom.agentik.standalone.agent - -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import kotlinx.io.files.Path -import kotlinx.io.files.SystemFileSystem -import kotlinx.io.files.SystemTemporaryDirectory -import pw.binom.agentik.memory.ConversationTurn -import pw.binom.agentik.memory.MemoryCategory -import pw.binom.agentik.memory.MemoryNote -import pw.binom.agentik.memory.MemoryPrefetcher -import pw.binom.agentik.memory.MemoryReviewDecision -import pw.binom.agentik.memory.MemoryReviewer -import pw.binom.agentik.memory.MemorySearchQuery -import pw.binom.agentik.memory.MemorySearchResult -import pw.binom.agentik.memory.MemoryStore -import pw.binom.agentik.memory.MemorySource -import pw.binom.agentik.memory.MemorySystemGuidance -import pw.binom.agentik.memory.NewMemoryNote -import pw.binom.agentik.memory.ReviewedTurn -import pw.binom.agentik.memory.md.openMdMemorySystem -import pw.binom.agentik.proto.Content -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.ksqlite.KsqliteStores -import pw.binom.agentik.llm.tools.ContextCompactor -import pw.binom.agentik.llm.tools.SummaryTurn -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlin.time.Instant - -/** - * Интеграция памяти в :standalone: - * - тулы memory_save/read/list/delete регистрируются у агента - * - prefetcher вставляет контекст в первое user-сообщение - * - reviewer пишет факты в store после хода - */ -class MemoryWiringTest { - - private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores - private lateinit var fakeLlm: FakeLiteLlm - private lateinit var root: Path - - @BeforeTest - fun setup() { - 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) - } - - @AfterTest - fun tearDown() { - sqliteStores.close() - runCatching { SystemFileSystem.delete(root, mustExist = false) } - } - - private fun newAgent( - memoryStore: MemoryStore, - prefetcher: MemoryPrefetcher, - reviewer: MemoryReviewer, - contextWindow: Int? = null, - compressionThreshold: Double = 0.8, - contextCompactor: ContextCompactor = EchoCompactor, - ): ChatAgent = ChatAgent( - id = "agentik", - conversationStore = sqliteStores.conversations, - messageStore = sqliteStores.messages, - workingMemoryStore = sqliteStores.workingMemory, - reflectionStore = sqliteStores.reflections, - llm = fakeLlm, - llmConfig = LlmConfig( - backend = LlmBackend.OPENAI, - systemPrompt = "be brief", - openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), - ), - memoryStore = memoryStore, - memoryPrefetcher = prefetcher, - memoryReviewer = reviewer, - contextWindow = contextWindow, - compressionThreshold = compressionThreshold, - contextCompactor = contextCompactor, - ) - - @Test - fun `system prompt includes memory guidance when memory is enabled`() = runBlocking { - val system = openMdMemorySystem(root) - val agent = newAgent(system.store, system.prefetcher, system.reviewer) - val conv = agent.createConversation(temp = false) - fakeLlm.reply = "ok" - conv.send(listOf(Content.Text("hi"))) - // System prompt не пишется в working_memory — читаем то, что увидит LLM - val text = fakeLlm.lastConfig?.systemInstruction - assertNotNull(text) - assertTrue(text!!.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(80)), - "system prompt should contain MEMORY_GUIDANCE; got first 200 chars: ${text.take(200)}") - agent.close() - system.close() - } - - @Test - fun `soul body is prepended to system prompt and wins over base`() = runBlocking { - val soulBody = "I am a helpful test persona. I always answer in one short line." - val agent = ChatAgent( - id = "agentik", - conversationStore = sqliteStores.conversations, - messageStore = sqliteStores.messages, - workingMemoryStore = sqliteStores.workingMemory, - reflectionStore = sqliteStores.reflections, - llm = fakeLlm, - llmConfig = LlmConfig( - backend = LlmBackend.OPENAI, - systemPrompt = "be brief", - openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), - ), - soulBody = soulBody, - ) - val conv = agent.createConversation(temp = false) - fakeLlm.reply = "ok" - conv.send(listOf(Content.Text("hi"))) - val text = fakeLlm.lastConfig?.systemInstruction - assertNotNull(text) - assertTrue(text!!.startsWith(soulBody), - "soul should be the very first section; got first 60 chars: ${text.take(60)}") - assertTrue(text.contains("be brief"), - "base prompt should still follow the soul; got: $text") - agent.close() - } - - @Test - fun `soul body not added when null`() = runBlocking { - val agent = ChatAgent( - id = "agentik", - conversationStore = sqliteStores.conversations, - messageStore = sqliteStores.messages, - workingMemoryStore = sqliteStores.workingMemory, - reflectionStore = sqliteStores.reflections, - llm = fakeLlm, - llmConfig = LlmConfig( - backend = LlmBackend.OPENAI, - systemPrompt = "be brief", - openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), - ), - ) - val conv = agent.createConversation(temp = false) - fakeLlm.reply = "ok" - conv.send(listOf(Content.Text("hi"))) - val text = fakeLlm.lastConfig?.systemInstruction - assertNotNull(text) - assertTrue(text!!.startsWith("be brief"), - "without soul, prompt should start with base; got first 60 chars: ${text.take(60)}") - agent.close() - } - - @Test - fun `agent exposes memory tools when store is configured`() { - val system = openMdMemorySystem(root) - val tools = MemoryToolsFactory.create(system.store) - assertEquals(4, tools.size) - val names = tools.map { it.name }.toSet() - assertEquals(setOf("memory_save", "memory_read", "memory_list", "memory_delete"), names) - // Каждый tool описывается валидной JSON-схемой: - for (t in tools) { - assertTrue(t.tool.describe().contains("\"description\""), "describe() for ${t.name}") - } - system.close() - } - - @Test - fun `memory_save tool round-trips a note through the store`() = runBlocking { - val system = openMdMemorySystem(root) - val tools = MemoryToolsFactory.create(system.store).associateBy { it.name } - val saveResult = tools.getValue("memory_save").tool.invoke( - """{"category":"preference","content":"prefers tabs over spaces"}""", - ) - assertTrue(saveResult.contains("\"ok\":true"), "save returned: $saveResult") - assertTrue(saveResult.contains("\"id\":\"mem-"), "save returned: $saveResult") - - val listResult = tools.getValue("memory_list").tool.invoke("""{"limit":10}""") - assertTrue(listResult.contains("prefers tabs over spaces"), - "list returned: $listResult") - - val readResult = tools.getValue("memory_read").tool.invoke( - """{"query":"tabs","top_k":3}""", - ) - assertTrue(readResult.contains("prefers tabs over spaces"), - "read returned: $readResult") - - val deleteResult = tools.getValue("memory_delete").tool.invoke( - Regex("\"id\":\"(mem-[^\"]+)\"").find(saveResult)?.let { m -> - """{"id":"${m.groupValues[1]}"}""" - } ?: error("save did not return id"), - ) - assertTrue(deleteResult.contains("\"ok\":true"), "delete returned: $deleteResult") - system.close() - } - - @Test - fun `prefetch inserts memory context into first user message`() = runTest { - // Сидим факт в store. - val store = openMdMemorySystem(root).also { - it.store.upsert( - MemoryNote( - id = "mem-pre", - category = MemoryCategory.USER, - content = "User runs k3s on Debian", - createdAt = Instant.parse("2026-09-14T10:00:00Z"), - lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"), - useCount = 0, - source = MemorySource.AGENT_SAVE, - ), - ) - } - val prefetcher = StaticPrefetcher { q, k -> - store.store.search(MemorySearchQuery(query = q, topK = k, category = null)) - } - fakeLlm.reply = "ok" - - val agent = newAgent(store.store, prefetcher, NoopReviewer()) - val conv = agent.createConversation(temp = false) as ChatConversation - conv.send(listOf(Content.Text("what's my k3s setup?"))) - - val sentText = fakeLlm.lastContents?.filterIsInstance() - ?.joinToString("\n") { it.text } - assertNotNull(sentText) - assertTrue(sentText.startsWith("[Memory context"), - "user message should start with memory prefix, got: $sentText") - assertTrue(sentText.contains("User runs k3s on Debian"), - "user message should include the prefetched note, got: $sentText") - assertTrue(sentText.contains("what's my k3s setup?"), - "user message should still contain the original text after the prefix, got: $sentText") - agent.close() - store.close() - } - - @Test - fun `prefetch does not add prefix when no hits`() = runTest { - val store = openMdMemorySystem(root) - val prefetcher = StaticPrefetcher { _, _ -> emptyList() } - fakeLlm.reply = "ok" - - val agent = newAgent(store.store, prefetcher, NoopReviewer()) - val conv = agent.createConversation(temp = false) as ChatConversation - conv.send(listOf(Content.Text("hello"))) - - val sentText = fakeLlm.lastContents?.filterIsInstance() - ?.joinToString("\n") { it.text } - assertNotNull(sentText) - assertTrue(!sentText.startsWith("[Memory context"), - "user message should not start with prefix when no hits, got: $sentText") - assertTrue(sentText.contains("hello")) - agent.close() - store.close() - } - - @Test - fun `reviewer fires on compaction (event-driven), not on every turn`() = runTest { - // После перехода на event-driven: review fires ТОЛЬКО в CompactionCoordinator.compactPreTurn() - // (через reviewer.reviewPreCompaction()) — не на каждый turn, не по interval'у. - // На простом turn без compaction review НЕ запускается. - val store = openMdMemorySystem(root) - fakeLlm.reply = "Sure, I'll remember that." - val reviewerInvoked = CompletableDeferred() - val reviewer = object : MemoryReviewer { - override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision = - error("review() не должен вызываться — только reviewPreCompaction() на compaction") - override suspend fun reviewPreCompaction(turns: List): MemoryReviewDecision { - reviewerInvoked.complete(Unit) - return MemoryReviewDecision( - toSave = listOf(NewMemoryNote(MemoryCategory.PREFERENCE, "prefers k8s")), - toDelete = emptyList(), - ) - } - } - - val agent = newAgent( - store.store, - StaticPrefetcher { _, _ -> emptyList() }, - reviewer, - contextWindow = 1024, // forceCompactNow требует contextWindow - ) - val conv = agent.createConversation(temp = false) as ChatConversation - - // Отправляем turn — ничего не должно триггериться, т.к. event-driven review - // fires только на compaction/closing. - conv.send(listOf(Content.Text("please note: I prefer k8s over docker swarm"))) - - // Ждём немного, проверяем что reviewer.review() НЕ был вызван - withContext(Dispatchers.Default.limitedParallelism(1)) { - delay(200) - } - assertTrue(store.store.list(category = MemoryCategory.PREFERENCE).isEmpty(), - "review НЕ должен был вызваться на простом turn без compaction") - - // Триггерим compaction вручную (через debug-API ConversationLoop.forceCompactNow). - // Это вызывает CompactionCoordinator.compactPreTurn → reviewer.reviewPreCompaction. - conv.forceCompactNow() - - // Дожидаемся reviewPreCompaction и upsert'а в IO-диспетчере. - withContext(Dispatchers.Default.limitedParallelism(1)) { - withTimeout(2_000) { reviewerInvoked.await() } - withTimeout(2_000) { - while (store.store.list(category = MemoryCategory.PREFERENCE).isEmpty()) delay(20) - } - } - - val notes = store.store.list(category = MemoryCategory.PREFERENCE) - assertEquals(1, notes.size, "reviewPreCompaction должен сохранить заметку") - assertEquals("prefers k8s", notes[0].content) - assertEquals(MemorySource.AUTO_REVIEW, notes[0].source) - agent.close() - store.close() - } -} - -// --- helpers --- - -private class StaticPrefetcher( - private val fn: suspend (String, Int) -> List, -) : MemoryPrefetcher { - override suspend fun prefetch(query: String, topK: Int, category: MemoryCategory?): List { - if (query.isBlank()) return emptyList() - return fn(query, topK).map { it.note } - } -} - -private class NoopReviewer : MemoryReviewer { - override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision = - MemoryReviewDecision(toSave = emptyList(), toDelete = emptyList()) -} - -/** Простой compactor для тестов: возвращает статичную строку. */ -private object EchoCompactor : ContextCompactor { - override suspend fun summarize(turns: List): String = - if (turns.isEmpty()) "" else "compacted-${turns.size}-turns" -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt deleted file mode 100644 index 001ccf4..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package pw.binom.agentik.standalone.agent - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import pw.binom.agentik.llm.tools.ReflectionParser - -class ReflectionParserTest { - - @Test - fun `parses clean JSON`() { - val raw = """{"score": 4, "summary": "ok", "weakSpots": ["a", "b"]}""" - val p = ReflectionParser.parse(raw) - assertNotNull(p) - assertEquals(4, p.score) - assertEquals("ok", p.summary) - assertEquals(listOf("a", "b"), p.weakSpots) - } - - @Test - fun `parses JSON wrapped in json fences`() { - val raw = "```json\n" + - "{\"score\": 3, \"summary\": \"norm\", \"weakSpots\": []}\n" + - "```" - val p = ReflectionParser.parse(raw) - assertNotNull(p) - assertEquals(3, p.score) - assertEquals(listOf(), p.weakSpots) - } - - @Test - fun `parses JSON with leading and trailing text`() { - val raw = "Вот мой ответ:\n" + - "{\"score\": 2, \"summary\": \"плохо\", \"weakSpots\": [\"путаю\", \"медленно\"]}\n" + - "Конец." - val p = ReflectionParser.parse(raw) - assertNotNull(p) - assertEquals(2, p.score) - assertEquals(listOf("путаю", "медленно"), p.weakSpots) - } - - @Test - fun `accepts score as string`() { - val raw = """{"score": "5", "summary": "ok", "weakSpots": []}""" - val p = ReflectionParser.parse(raw) - assertNotNull(p) - assertEquals(5, p.score) - } - - @Test - fun `returns null on missing score`() { - val raw = """{"summary": "x", "weakSpots": []}""" - assertNull(ReflectionParser.parse(raw)) - } - - @Test - fun `returns null on invalid JSON`() { - assertNull(ReflectionParser.parse("not even json")) - } - - @Test - fun `handles escape sequences in weakSpots`() { - // raw содержит 4 backslashes подряд; парсер \\ → \, итого 2 backslashes в результате - val raw = """{"score": 3, "summary": "ok", "weakSpots": ["path\\\\file"]}""" - val p = ReflectionParser.parse(raw) - assertNotNull(p) - // парсер снимает один escape: \\\\ → \\ - assertEquals(listOf("path\\\\file"), p.weakSpots) - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMinerTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMinerTest.kt deleted file mode 100644 index 2ebcc38..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMinerTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package pw.binom.agentik.standalone.agent - -import kotlinx.coroutines.runBlocking -import pw.binom.agentik.memory.ConversationTurn -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import pw.binom.agentik.llm.tools.SkillMiner - -class SkillMinerTest { - - private fun turns(n: Int): List = (1..n).map { - ConversationTurn(userMessage = "q$it", assistantMessage = "a$it") - } - - @Test - fun `mine returns parsed skills from model JSON reply`() = runBlocking { - val llm = FakeLiteLlm() - llm.reply = """{"skills": [{"name": "n", "description": "d", "body": "b"}]}""" - val miner = SkillMiner(llm, maxTurns = 10, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined) - val out = miner.mine(turns(5), existing = emptyList()) - assertEquals(1, out.size) - assertEquals("n", out[0].name) - } - - @Test - fun `mine with empty reply returns empty`() = runBlocking { - val llm = FakeLiteLlm() - llm.reply = "{}" - val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined) - assertTrue(miner.mine(turns(5), emptyList()).isEmpty()) - } - - @Test - fun `miner failure degrades to empty list`() = runBlocking { - val llm = FakeLiteLlm() - llm.failMessage = "onnx died" - val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined) - assertTrue(miner.mine(turns(5), emptyList()).isEmpty()) - } - - @Test - fun `miner respects maxTurns`() = runBlocking { - val llm = FakeLiteLlm() - llm.reply = """{"skills": []}""" - val miner = SkillMiner(llm, maxTurns = 2, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined) - miner.mine(turns(30), emptyList()) - val prompt = llm.lastContents!!.first().let { - val p = it as pw.binom.litert.LiteContentPart.Text - p.text - } - // В промпт попало только последние 2 хода из 30. - assertTrue(prompt.contains("q29"), "last turns missing: $prompt") - assertTrue(!prompt.contains("q1\n"), "old turn leaked: $prompt") - } - - @Test - fun `empty turns short-circuit without LLM call`() = runBlocking { - val llm = FakeLiteLlm() - llm.failMessage = "should not be called" - val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined) - val out = miner.mine(emptyList(), emptyList()) - assertTrue(out.isEmpty()) - assertTrue(llm.conversations.isEmpty(), "LLM must not be called for empty input") - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParserTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParserTest.kt deleted file mode 100644 index 8a20474..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParserTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -package pw.binom.agentik.standalone.agent - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import pw.binom.agentik.llm.tools.SkillMiningParser - -class SkillMiningParserTest { - - @Test - fun `parses clean JSON`() { - val raw = """{"skills": [{"name": "backend:spring:db", "description": "x", "body": "# step 1"}]}""" - val out = SkillMiningParser.parse(raw) - assertEquals(1, out.size) - assertEquals("backend:spring:db", out[0].name) - assertEquals("x", out[0].description) - assertEquals("# step 1", out[0].body) - } - - @Test - fun `parses JSON wrapped in fences with prose around`() { - val raw = "Окей, вот что я нашёл:\n```json\n" + - "{\"skills\": [{\"name\": \"a\", \"description\": \"d\", \"body\": \"b\"}]}\n" + - "```\nНадеюсь, помогло." - val out = SkillMiningParser.parse(raw) - assertEquals(1, out.size) - assertEquals("a", out[0].name) - } - - @Test - fun `parses bare array without skills key`() { - val raw = """[{"name": "x", "description": "d", "body": "b"}, {"name": "y"}]""" - val out = SkillMiningParser.parse(raw) - assertEquals(2, out.size) - assertEquals("x", out[0].name) - assertEquals("", out[1].body) - } - - @Test - fun `unescapes newlines and quotes in body`() { - val raw = "{\"skills\": [{\"name\": \"n\", \"description\": \"\", \"body\": \"line1\\nline2\\n\\nwith \\\"quotes\\\" and backslash \\\\\\\"\"}]}" - val out = SkillMiningParser.parse(raw) - assertEquals(1, out.size) - val body = out[0].body - assertTrue(body.contains("line1\nline2"), "body: $body") - assertTrue(body.contains("\"quotes\""), "body: $body") - } - - @Test - fun `empty skills array returns empty list`() { - val out = SkillMiningParser.parse("""{"skills": []}""") - assertTrue(out.isEmpty()) - } - - @Test - fun `model chatter with no JSON returns empty`() { - val out = SkillMiningParser.parse("Скилов не нашёл, всё чисто.") - assertTrue(out.isEmpty()) - } - - @Test - fun `truncated JSON returns empty`() { - val out = SkillMiningParser.parse("""{"skills": [{"name": "a", "description": "d", "body": """"") - assertTrue(out.isEmpty()) - } - - @Test - fun `skills without name are dropped`() { - val raw = """{"skills": [{"description": "no name"}, {"name": "ok"}]}""" - val out = SkillMiningParser.parse(raw) - assertEquals(1, out.size) - assertEquals("ok", out[0].name) - } - - @Test - fun `nested braces inside strings do not break balance`() { - val raw = """{"skills": [{"name": "n", "description": "d", "body": "echo '{\"k\": 1}'"}]}""" - val out = SkillMiningParser.parse(raw) - assertEquals(1, out.size) - assertEquals("echo '{\"k\": 1}'", out[0].body) - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt deleted file mode 100644 index 8e66da7..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package pw.binom.agentik.standalone.agent - -import pw.binom.agentik.skills.SkillCatalog -import pw.binom.agentik.skills.SkillFile -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class SkillReadToolTest { - - private val catalog = SkillCatalog( - listOf( - SkillFile(name = "lint", description = "lint things", body = "# Lint\nRun the linter."), - SkillFile(name = "empty", description = "no body", body = ""), - ), - ) - private val tool = SkillReadTool(catalog) - - @Test - fun describeIsFlatOpenApiSchema() { - val json = tool.describe() - // Flat OpenAPI-спец (формат LiteRT-LM): name/description/parameters на верхнем уровне. - assertTrue("\"name\"" in json) - assertTrue(SkillReadTool.NAME in json) - assertTrue("parameters" in json) - assertTrue(!("\"type\":\"function\"" in json || "\"type\": \"function\"" in json)) - } - - @Test - fun invokeReturnsBodyForKnownSkill() { - val result = tool.invoke("""{"name":"lint"}""") - assertEquals("# Lint\nRun the linter.", result) - } - - @Test - fun invokeUnknownSkillListsAvailable() { - val result = tool.invoke("""{"name":"nope"}""") - assertTrue("unknown skill" in result) - assertTrue("lint" in result) - assertTrue("empty" in result) - } - - @Test - fun invokeEmptyBodyGivesPlaceholder() { - val result = tool.invoke("""{"name":"empty"}""") - assertTrue("empty body" in result) - } - - @Test - fun invokeMissingNameIsError() { - val result = tool.invoke("{}") - assertTrue("[tool error]" in result) - assertTrue("name" in result) - } - - @Test - fun invokeInvalidJsonIsError() { - assertTrue("[tool error]" in tool.invoke("not json")) - } - - @Test - fun invokeBlankArgumentsIsError() { - assertTrue("[tool error]" in tool.invoke("")) - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt deleted file mode 100644 index d7f21d4..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillToolsTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -package pw.binom.agentik.standalone.agent - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import java.io.File -import kotlin.uuid.Uuid -import kotlinx.coroutines.runBlocking -import pw.binom.agentik.skills.DiskSkillStore -import pw.binom.agentik.skills.SkillCatalog -import pw.binom.agentik.skills.SkillFile -import pw.binom.agentik.skills.SkillStore - -class SkillToolsTest { - - @Test - fun `SkillSaveTool persists and surfaces in catalog`() { - val dir = tempSkillDir() - val store: SkillStore = DiskSkillStore(dir) - val tool = SkillSaveTool(store) - - val arguments = """{"name":"my-skill","description":"Test skill","body":"# Hello"}""" - val result = tool.invoke(arguments) - assertTrue(result.contains("\"ok\":true"), "expected success, got: $result") - assertTrue(result.contains("my-skill")) - - runBlocking { - val reloaded = SkillCatalog(store.catalog.skills) - assertEquals(1, reloaded.skills.size) - val s = reloaded.skills.first() - assertEquals("my-skill", s.name) - assertEquals("Test skill", s.description) - assertEquals("# Hello", s.body) - } - - // Должен появиться файл на диске - assertTrue(File(dir, "my-skill/SKILL.md").exists()) - } - - @Test - fun `SkillSaveTool rejects blank name`() { - val dir = tempSkillDir() - val store = DiskSkillStore(dir) - val tool = SkillSaveTool(store) - val arguments = """{"name":"","description":"x","body":"y"}""" - // Ожидаем ошибку - try { - tool.invoke(arguments) - error("should have thrown") - } catch (e: IllegalStateException) { - assertTrue(e.message!!.contains("skill name", ignoreCase = true)) - } - } - - @Test - fun `SkillDeleteTool archives the skill`() { - val dir = tempSkillDir() - val store = DiskSkillStore(dir) - store.upsert(SkillFile(name = "to-delete", description = "x", body = "y")) - - val tool = SkillDeleteTool(store) - val result = tool.invoke("""{"name":"to-delete"}""") - assertTrue(result.contains("\"ok\":true")) - assertTrue(result.contains("to-delete")) - - // Скил исчез из каталога - assertTrue(store.catalog.skills.none { it.name == "to-delete" }) - // Файл переименован в .archived - assertTrue(File(dir, "to-delete/SKILL.md.archived").exists()) - } - - @Test - fun `SkillDeleteTool returns error for missing skill`() { - val dir = tempSkillDir() - val store = DiskSkillStore(dir) - val tool = SkillDeleteTool(store) - try { - tool.invoke("""{"name":"does-not-exist"}""") - error("should have thrown") - } catch (e: IllegalStateException) { - assertTrue(e.message!!.contains("not found", ignoreCase = true)) - } - } - - @Test - fun `colon-named skills map to nested directories`() { - val dir = tempSkillDir() - val store = DiskSkillStore(dir) - store.upsert(SkillFile(name = "backend:spring:db-base", description = "x", body = "y")) - assertTrue(File(dir, "backend/spring/db-base/SKILL.md").exists()) - } - - private fun tempSkillDir(): File { - val dir = File(System.getProperty("java.io.tmpdir"), "agentik-skills-${Uuid.random()}") - dir.deleteOnExit() - return dir - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/TestMemoryStores.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/TestMemoryStores.kt deleted file mode 100644 index 97c83d4..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/TestMemoryStores.kt +++ /dev/null @@ -1,55 +0,0 @@ -package pw.binom.agentik.standalone.agent - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import pw.binom.agentik.memory.MemoryCategory -import pw.binom.agentik.memory.MemoryNote -import pw.binom.agentik.memory.MemorySearchQuery -import pw.binom.agentik.memory.MemorySearchResult -import pw.binom.agentik.memory.MemoryStore -import pw.binom.agentik.memory.MemoryStoreEvent -import kotlin.time.Instant - -/** - * Простой in-memory MemoryStore для тестов. - * - * Не зависит от :memory-md / :memory-vector; идёт через `archiveStale` из - * default-имплементации MemoryStore. - */ -class TestInMemoryMemoryStore : MemoryStore { - private val notes = mutableMapOf() - private val ev = MutableSharedFlow(extraBufferCapacity = 16) - - override suspend fun upsert(note: MemoryNote) { - notes[note.id] = note - ev.tryEmit(MemoryStoreEvent.Upserted(note)) - } - override suspend fun get(id: String): MemoryNote? = notes[id] - override suspend fun list( - category: MemoryCategory?, - conversationId: String?, - limit: Int, - offset: Int, - ): List = - notes.values - .filter { category == null || it.category == category } - .drop(offset) - .take(limit) - override suspend fun search(query: MemorySearchQuery): List = - notes.values - .filter { query.category == null || it.category == query.category } - .map { MemorySearchResult(it, 1.0f) } - .take(query.topK) - override suspend fun delete(id: String): Boolean { - val ok = notes.remove(id) != null - if (ok) ev.tryEmit(MemoryStoreEvent.Deleted(id)) - return ok - } - override suspend fun markUsed(id: String, at: Instant) { - notes[id]?.let { notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1) } - } - override fun events(): Flow = ev.asSharedFlow() - override fun close() {} - fun allIds(): List = notes.keys.sorted() -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/CuratorTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/CuratorTest.kt deleted file mode 100644 index 839b85f..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/CuratorTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package pw.binom.agentik.standalone.agent.memory - -import kotlinx.coroutines.test.runTest -import pw.binom.agentik.standalone.agent.TestInMemoryMemoryStore -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.time.Clock -import kotlin.time.Duration -import kotlin.time.Instant -import pw.binom.agentik.memory.MemoryCategory -import pw.binom.agentik.memory.MemoryNote -import pw.binom.agentik.memory.MemorySource - -class CuratorTest { - - @Test - fun `archives notes older than maxAge with zero useCount`() = runTest { - val store = TestInMemoryMemoryStore() - val now = Instant.parse("2026-09-15T00:00:00Z") - store.upsert(note("old", lastUsedOffsetDays = 100, useCount = 0)) - store.upsert(note("fresh", lastUsedOffsetDays = 1, useCount = 0)) - val curator = Curator(store, maxAge = Duration.parse("90d"), clock = FakeClock(now)) - assertEquals(1, curator.runPass()) - assertEquals(listOf("fresh"), store.allIds()) - } - - @Test - fun `keeps notes that were used recently even if old`() = runTest { - val store = TestInMemoryMemoryStore() - val now = Instant.parse("2026-09-15T00:00:00Z") - store.upsert(note("frequently-used", lastUsedOffsetDays = 1, useCount = 50)) - val curator = Curator(store, maxAge = Duration.parse("90d"), clock = FakeClock(now)) - assertEquals(0, curator.runPass()) - assertEquals(listOf("frequently-used"), store.allIds()) - } - - @Test - fun `archives notes with useCount above default when configured`() = runTest { - val store = TestInMemoryMemoryStore() - val now = Instant.parse("2026-09-15T00:00:00Z") - store.upsert(note("twice-used", lastUsedOffsetDays = 100, useCount = 2)) - val curator = Curator( - store, - maxAge = Duration.parse("90d"), - maxUseCount = 5, - clock = FakeClock(now), - ) - assertEquals(1, curator.runPass()) - assertTrue(store.allIds().isEmpty()) - } - - @Test - fun `start and stop launch and cancel the background loop`() = runTest { - val store = TestInMemoryMemoryStore() - val curator = Curator( - store, - interval = Duration.parse("10ms"), - maxAge = Duration.parse("90d"), - ) - curator.start() - Thread.sleep(50) - curator.stop() - // ничего не падает, корутина отменена - } - - private fun note( - id: String, - lastUsedOffsetDays: Long, - useCount: Int, - ): MemoryNote { - val now = Instant.parse("2026-09-15T00:00:00Z") - return MemoryNote( - id = id, - category = MemoryCategory.WORLD, - content = "fact $id", - createdAt = now - Duration.parse("${lastUsedOffsetDays}d"), - lastUsedAt = now - Duration.parse("${lastUsedOffsetDays}d"), - useCount = useCount, - conversationId = null, - source = MemorySource.AGENT_SAVE, - ) - } - - private class FakeClock(private val now: Instant) : Clock { - override fun now(): Instant = now - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt deleted file mode 100644 index fb89e8a..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt +++ /dev/null @@ -1,186 +0,0 @@ -package pw.binom.agentik.standalone.agent.memory - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.runTest -import pw.binom.agentik.memory.MemoryCategory -import pw.binom.agentik.memory.MemoryNote -import pw.binom.agentik.memory.MemorySource -import pw.binom.agentik.memory.MemoryStore -import pw.binom.agentik.memory.MemoryStoreEvent -import pw.binom.agentik.memory.ReviewedTurn -import pw.binom.agentik.standalone.agent.FakeLiteLlm -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlin.time.Instant -import pw.binom.agentik.llm.tools.LlmMemoryReviewer -import pw.binom.agentik.llm.tools.ReviewPrompts - -class LlmMemoryReviewerTest { - - /** - * Минимальный in-memory store для тестов — реализует [MemoryStore], - * хранит заметки в MutableList, поддерживает events flow. - */ - private class InMemoryStore : MemoryStore { - private val notes = mutableMapOf() - private val _events = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 16) - - override suspend fun upsert(note: MemoryNote) { - notes[note.id] = note - _events.emit(MemoryStoreEvent.Upserted(note)) - } - - override suspend fun get(id: String): MemoryNote? = notes[id] - override suspend fun list( - category: MemoryCategory?, - conversationId: String?, - limit: Int, - offset: Int, - ): List = notes.values - .filter { category == null || it.category == category } - .filter { conversationId == null || it.conversationId == conversationId } - .sortedByDescending { it.lastUsedAt } - .drop(offset) - .take(limit) - - override suspend fun search(query: pw.binom.agentik.memory.MemorySearchQuery): List = emptyList() - - override suspend fun delete(id: String): Boolean = notes.remove(id) != null - - override suspend fun markUsed(id: String, at: Instant) { - notes[id]?.let { - notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1) - } - } - - override fun events(): kotlinx.coroutines.flow.Flow = _events - - override fun close() {} - - // Helper for tests to seed notes - fun seed(note: MemoryNote) { - notes[note.id] = note - } - } - - @Test - fun `review parses save JSON and applies upsert`() = runTest { - val llm = FakeLiteLlm().apply { - reply = """{"save":[{"category":"USER","content":"Имя — Саша"}],"delete":[]}""" - } - val store = InMemoryStore() - val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) - - val decision = reviewer.review( - ReviewedTurn( - userMessage = "Меня Саша зовут", - assistantMessage = "Приятно познакомиться, Саша!", - ) - ) - assertEquals(1, decision.toSave.size) - assertEquals(MemoryCategory.USER, decision.toSave[0].category) - - val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW) - assertEquals(1, applied.saved) - - val all = store.list() - assertEquals(1, all.size) - assertEquals("Имя — Саша", all[0].content) - assertEquals(MemorySource.AUTO_REVIEW, all[0].source) - } - - @Test - fun `review applies delete decisions`() = runTest { - val llm = FakeLiteLlm().apply { - reply = """{"save":[],"delete":["mem-stale-1"]}""" - } - val store = InMemoryStore().apply { - seed( - MemoryNote( - id = "mem-stale-1", - category = MemoryCategory.USER, - content = "stale", - createdAt = Instant.parse("2026-01-01T00:00:00Z"), - lastUsedAt = Instant.parse("2026-01-01T00:00:00Z"), - useCount = 0, - source = MemorySource.AUTO_REVIEW, - ) - ) - } - val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) - - val decision = reviewer.review(ReviewedTurn("удали это", "ок")) - val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW) - assertEquals(0, applied.saved) - assertEquals(1, applied.deleted) - assertEquals(0, store.list().size) - } - - @Test - fun `review returns empty decision when LLM produces garbage`() = runTest { - val llm = FakeLiteLlm().apply { reply = "Извини, я не могу помочь с этим." } - val store = InMemoryStore() - val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) - - val decision = reviewer.review(ReviewedTurn("hi", "hello")) - assertTrue(decision.toSave.isEmpty()) - assertTrue(decision.toDelete.isEmpty()) - } - - @Test - fun `review handles empty LLM reply`() = runTest { - val llm = FakeLiteLlm().apply { reply = "" } - val store = InMemoryStore() - val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) - - val decision = reviewer.review(ReviewedTurn("hi", "hello")) - assertTrue(decision.toSave.isEmpty()) - } - - @Test - fun `review creates conversation with review system prompt`() = runTest { - val llm = FakeLiteLlm().apply { - reply = """{"save":[],"delete":[]}""" - } - val store = InMemoryStore() - val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) - - reviewer.review(ReviewedTurn("u", "a")) - - assertNotNull(llm.lastConfig) - assertEquals(ReviewPrompts.REVIEW_SYSTEM_PROMPT, llm.lastConfig!!.systemInstruction) - } - - @Test - fun `reviewPreCompaction processes batch of turns`() = runTest { - val llm = FakeLiteLlm().apply { - reply = """ - {"save":[ - {"category":"USER","content":"Работает в Яндексе"}, - {"category":"WORLD","content":"JVector — pure-Java ANN"} - ],"delete":[]} - """.trimIndent() - } - val store = InMemoryStore() - val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) - - val turns = listOf( - pw.binom.agentik.memory.ConversationTurn( - userMessage = "Я в Яндексе работаю", - assistantMessage = "Круто!", - ), - pw.binom.agentik.memory.ConversationTurn( - userMessage = "А что за JVector?", - assistantMessage = "ANN-библиотека на Java.", - ), - ) - - val decision = reviewer.reviewPreCompaction(turns) - val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW) - - assertEquals(2, applied.saved) - assertEquals(2, store.list().size) - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt deleted file mode 100644 index e45c32a..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -package pw.binom.agentik.standalone.agent.memory - -import pw.binom.agentik.memory.MemoryCategory -import pw.binom.agentik.memory.MemoryReviewDecision -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import pw.binom.agentik.llm.tools.ReviewDecisionParser - -class ReviewDecisionParserTest { - - @Test - fun `parses save array with USER category`() { - val raw = """{"save":[{"category":"USER","content":"Имя пользователя — Саша"}],"delete":[]}""" - val decision = ReviewDecisionParser.parse(raw) - assertEquals(1, decision.toSave.size) - assertEquals(MemoryCategory.USER, decision.toSave[0].category) - assertEquals("Имя пользователя — Саша", decision.toSave[0].content) - assertTrue(decision.toDelete.isEmpty()) - } - - @Test - fun `parses all three categories`() { - val raw = """ - {"save":[ - {"category":"USER","content":"Работает в Яндексе"}, - {"category":"WORLD","content":"JVector — pure-Java ANN от DataStax"}, - {"category":"PREFERENCE","content":"Отвечать кратко"} - ],"delete":[]} - """.trimIndent() - val decision = ReviewDecisionParser.parse(raw) - assertEquals(3, decision.toSave.size) - assertEquals(MemoryCategory.USER, decision.toSave[0].category) - assertEquals(MemoryCategory.WORLD, decision.toSave[1].category) - assertEquals(MemoryCategory.PREFERENCE, decision.toSave[2].category) - } - - @Test - fun `parses delete array with ids`() { - val raw = """{"save":[],"delete":["mem-123","mem-456"]}""" - val decision = ReviewDecisionParser.parse(raw) - assertTrue(decision.toSave.isEmpty()) - assertEquals(listOf("mem-123", "mem-456"), decision.toDelete) - } - - @Test - fun `returns empty decision on empty input`() { - assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse("")) - assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(" ")) - } - - @Test - fun `returns empty decision on non-JSON garbage`() { - val raw = "Извини, я не могу помочь с этим." - assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw)) - } - - @Test - fun `returns empty decision on malformed JSON`() { - val raw = """{"save":[{"category":"USER","content":"foo""" // truncated - assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw)) - } - - @Test - fun `extracts JSON from markdown code block`() { - val raw = """ - Вот JSON: - ```json - {"save":[{"category":"WORLD","content":"SQLite 3.51"}],"delete":[]} - ``` - """.trimIndent() - val decision = ReviewDecisionParser.parse(raw) - assertEquals(1, decision.toSave.size) - assertEquals(MemoryCategory.WORLD, decision.toSave[0].category) - assertEquals("SQLite 3.51", decision.toSave[0].content) - } - - @Test - fun `skips entries with unknown category`() { - val raw = """{"save":[ - {"category":"USER","content":"valid"}, - {"category":"NOT_A_CATEGORY","content":"should be skipped"}, - {"category":"WORLD","content":"valid too"} - ],"delete":[]}""" - val decision = ReviewDecisionParser.parse(raw) - assertEquals(2, decision.toSave.size) - assertEquals("valid", decision.toSave[0].content) - assertEquals("valid too", decision.toSave[1].content) - } - - @Test - fun `skips entries with blank content`() { - val raw = """{"save":[ - {"category":"USER","content":""}, - {"category":"WORLD","content":" "} - ],"delete":[]}""" - assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw)) - } - - @Test - fun `handles escaped quotes in content`() { - val raw = """{"save":[{"category":"USER","content":"Сказал \"привет\""}],"delete":[]}""" - val decision = ReviewDecisionParser.parse(raw) - assertEquals(1, decision.toSave.size) - assertEquals("Сказал \"привет\"", decision.toSave[0].content) - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AppConfigTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AppConfigTest.kt deleted file mode 100644 index 9aa9742..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AppConfigTest.kt +++ /dev/null @@ -1,199 +0,0 @@ -package pw.binom.agentik.standalone.config - -import kotlinx.serialization.json.Json -import pw.binom.agentik.standalone.llm.LlmBackend -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertTrue - -class AppConfigTest { - - private fun openAiEnv( - extra: Map = emptyMap(), - ): (String) -> String? = { name -> - when (name) { - "OPENAI_BASE_URL" -> "https://api.openai.com/v1" - "OPENAI_API_KEY" -> "sk-test" - "OPENAI_MODEL" -> "gpt-4o-mini" - else -> extra[name] - } - } - - @Test - fun `defaults applied when port and db path absent`() { - val cfg = AppConfig.fromEnv(openAiEnv()) - assertEquals(AppConfig.DEFAULT_PORT, cfg.agent.port) - assertEquals(AppConfig.DEFAULT_DB_PATH, cfg.agent.dbPath) - } - - @Test - fun `reads port and db path from env`() { - val cfg = AppConfig.fromEnv( - openAiEnv(mapOf("AGENTIK_PORT" to "9999", "AGENTIK_DB_PATH" to "/tmp/x.db")), - ) - assertEquals(9999, cfg.agent.port) - assertEquals("/tmp/x.db", cfg.agent.dbPath) - } - - @Test - fun `blank db path falls back to default`() { - val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_DB_PATH" to " "))) - assertEquals(AppConfig.DEFAULT_DB_PATH, cfg.agent.dbPath) - } - - @Test - fun `non-numeric port falls back to default`() { - val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_PORT" to "not-a-port"))) - assertEquals(AppConfig.DEFAULT_PORT, cfg.agent.port) - } - - @Test - fun `delegates llm to AppConfig fromEnv`() { - val cfg = AppConfig.fromEnv(openAiEnv()) - assertEquals(LlmBackend.OPENAI, cfg.llm.backend) - assertEquals("gpt-4o-mini", cfg.llm.openai?.model) - } - - @Test - fun `google backend is selected via env`() { - val cfg = AppConfig.fromEnv { name -> - when (name) { - "AGENTIK_LLM_BACKEND" -> "google" - "AGENTIK_GOOGLE_MODEL_PATH" -> "/models/gemma.litertlm" - else -> null - } - } - assertEquals(LlmBackend.GOOGLE, cfg.llm.backend) - assertEquals("/models/gemma.litertlm", cfg.llm.google?.modelPath) - } - - @Test - fun `missing required llm env propagates`() { - assertFailsWith { - AppConfig.fromEnv { name -> if (name == "OPENAI_BASE_URL") "x" else null } - } - } - - @Test - fun `mcp empty when no config path`() { - val cfg = AppConfig.fromEnv(openAiEnv()) - assertTrue(cfg.mcp.isEmpty) - } - - @Test - fun `mcp loaded from file`() { - val tmp = kotlin.io.path.createTempFile(suffix = ".json").toFile() - try { - tmp.writeText( - """ - { - "mcpServers": { - "fetch": { "command": "uvx", "args": ["mcp-server-fetch"] } - } - } - """.trimIndent(), - ) - val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_MCP_CONFIG" to tmp.absolutePath))) - assertEquals(1, cfg.mcp.servers.size) - assertEquals("fetch", cfg.mcp.servers.first().name) - } finally { - tmp.delete() - } - } - - @Test - fun `skills dir defaults to null`() { - assertEquals(null, AppConfig.fromEnv(openAiEnv()).agent.skillsDir) - } - - @Test - fun `skills dir read from env`() { - val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to "/skills"))) - assertEquals("/skills", cfg.agent.skillsDir) - } - - @Test - fun `blank skills dir falls back to null`() { - val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to " "))) - assertEquals(null, cfg.agent.skillsDir) - } - - @Test - fun `soul path defaults to null`() { - assertEquals(null, AppConfig.fromEnv(openAiEnv()).agent.soulPath) - } - - @Test - fun `soul path read from env`() { - val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to "/etc/SOUL.md"))) - assertEquals("/etc/SOUL.md", cfg.agent.soulPath) - } - - @Test - fun `blank soul path falls back to null`() { - val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to " "))) - assertEquals(null, cfg.agent.soulPath) - } - - @Test - fun `serialization round-trips through json`() { - val original = AppConfig.fromEnv( - openAiEnv( - mapOf( - "AGENTIK_PORT" to "7777", - "AGENTIK_DB_PATH" to "/tmp/x.db", - "AGENTIK_SYSTEM_PROMPT" to "be brief", - "AGENTIK_SKILLS_DIR" to "/skills", - ), - ), - ).copy( - mcp = pw.binom.agentik.mcp.bridge.McpConfig( - servers = listOf( - pw.binom.agentik.mcp.bridge.McpServerSpec.Stdio( - name = "fetch", - command = "uvx", - args = listOf("mcp-server-fetch"), - ), - pw.binom.agentik.mcp.bridge.McpServerSpec.Http( - name = "remote", - url = "https://example.com/mcp", - headers = mapOf("Authorization" to "Bearer x"), - ), - ), - ), - ) - - val json = Json { encodeDefaults = true } - val text = json.encodeToString(AppConfig.serializer(), original) - val restored = json.decodeFromString(AppConfig.serializer(), text) - - assertEquals(original, restored) - } - - @Test - fun `compressionThreshold defaults to 0_8 when env unset`() { - val cfg = AppConfig.fromEnv(openAiEnv()) - assertEquals(0.8, cfg.memory.compressionThreshold) - } - - @Test - fun `compressionThreshold parsed from env`() { - val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.6"))) - assertEquals(0.6, cfg.memory.compressionThreshold) - } - - @Test - fun `compressionThreshold clamped between min and max`() { - val tooLow = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.01"))) - assertEquals(0.1, tooLow.memory.compressionThreshold) - val tooHigh = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "1.5"))) - assertEquals(0.99, tooHigh.memory.compressionThreshold) - } - - @Test - fun `compressionThreshold garbage falls back to default`() { - val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "хрен"))) - assertEquals(0.8, cfg.memory.compressionThreshold) - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/llm/LlmConfigTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/llm/LlmConfigTest.kt deleted file mode 100644 index 99e491a..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/llm/LlmConfigTest.kt +++ /dev/null @@ -1,145 +0,0 @@ -package pw.binom.agentik.standalone.llm - -import pw.binom.agentik.standalone.config.AppConfig -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -class LlmConfigTest { - - private fun openAiEnv(extra: Map = emptyMap()): (String) -> String? = { name -> - when (name) { - "OPENAI_BASE_URL" -> "https://api.openai.com/v1" - "OPENAI_API_KEY" -> "sk-test" - "OPENAI_MODEL" -> "gpt-4o-mini" - else -> extra[name] - } - } - - @Test - fun `fromEnv - happy path`() { - val cfg = AppConfig.fromEnv { name -> - when (name) { - "OPENAI_BASE_URL" -> "https://api.openai.com/v1" - "OPENAI_API_KEY" -> "sk-test" - "OPENAI_MODEL" -> "gpt-4o-mini" - "AGENTIK_SYSTEM_PROMPT" -> "be brief" - else -> null - } - }.llm - assertEquals("be brief", cfg.systemPrompt) - assertEquals(OpenAiConfig(baseUrl = "https://api.openai.com/v1", apiKey = "sk-test", model = "gpt-4o-mini"), cfg.openai) - } - - @Test - fun `fromEnv - falls back to default system prompt`() { - val cfg = AppConfig.fromEnv(openAiEnv()).llm - assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt) - } - - @Test - fun `fromEnv - missing base url throws`() { - assertFailsWith { - AppConfig.fromEnv { name -> - when (name) { - "OPENAI_API_KEY" -> "sk-test" - "OPENAI_MODEL" -> "gpt-4o-mini" - else -> null - } - } - } - } - - @Test - fun `fromEnv - missing api key throws`() { - assertFailsWith { - AppConfig.fromEnv { name -> - when (name) { - "OPENAI_BASE_URL" -> "https://api.openai.com/v1" - "OPENAI_MODEL" -> "gpt-4o-mini" - else -> null - } - } - } - } - - @Test - fun `fromEnv - missing model throws`() { - assertFailsWith { - AppConfig.fromEnv { name -> - when (name) { - "OPENAI_BASE_URL" -> "https://api.openai.com/v1" - "OPENAI_API_KEY" -> "sk-test" - else -> null - } - } - } - } - - @Test - fun `blank system prompt from env falls back to default`() { - val cfg = AppConfig.fromEnv { name -> - when (name) { - "OPENAI_BASE_URL" -> "https://api.openai.com/v1" - "OPENAI_API_KEY" -> "sk-test" - "OPENAI_MODEL" -> "gpt-4o-mini" - "AGENTIK_SYSTEM_PROMPT" -> " " - else -> null - } - }.llm - assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt) - } - - @Test - fun `fromEnv - OPENAI_CONTEXT_WINDOW parsed into OpenAiConfig`() { - val cfg = AppConfig.fromEnv { name -> - when (name) { - "OPENAI_BASE_URL" -> "https://api.openai.com/v1" - "OPENAI_API_KEY" -> "sk-test" - "OPENAI_MODEL" -> "gpt-4o-mini" - "OPENAI_CONTEXT_WINDOW" -> "128000" - else -> null - } - }.llm - assertEquals(128_000, cfg.openai?.contextWindow) - } - - @Test - fun `contextWindow - env parsed once and exposed via derived prop`() { - // Старый resolveContextWindow проверял "env wins over config" — но в новой - // модели env и config это одно и то же значение (env читается один раз в - // AppConfig.fromEnv и сохраняется в OpenAiConfig.contextWindow). Поэтому - // тут проверяем, что derived-prop LlmConfig.contextWindow правильно - // прокидывает значение из OpenAiConfig для активного бэкенда. - val cfg = AppConfig.fromEnv { name -> - when (name) { - "OPENAI_BASE_URL" -> "https://api.openai.com/v1" - "OPENAI_API_KEY" -> "sk-test" - "OPENAI_MODEL" -> "gpt-4o-mini" - "OPENAI_CONTEXT_WINDOW" -> "64000" - else -> null - } - }.llm - assertEquals(64_000, cfg.contextWindow) - } - - @Test - fun `contextWindow - returns null when nothing set`() { - val cfg = AppConfig.fromEnv(openAiEnv()).llm - assertEquals(null, cfg.contextWindow) - } - - @Test - fun `fromEnv - OPENAI_CONTEXT_WINDOW garbage falls back to null`() { - val cfg = AppConfig.fromEnv { name -> - when (name) { - "OPENAI_BASE_URL" -> "https://api.openai.com/v1" - "OPENAI_API_KEY" -> "sk-test" - "OPENAI_MODEL" -> "gpt-4o-mini" - "OPENAI_CONTEXT_WINDOW" -> "не-число" - else -> null - } - }.llm - assertEquals(null, cfg.openai?.contextWindow) - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/llm/ModelDownloaderTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/llm/ModelDownloaderTest.kt deleted file mode 100644 index db3259f..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/llm/ModelDownloaderTest.kt +++ /dev/null @@ -1,174 +0,0 @@ -package pw.binom.agentik.standalone.llm - -import io.ktor.client.HttpClient -import io.ktor.server.application.call -import io.ktor.server.cio.CIO -import io.ktor.server.engine.embeddedServer -import io.ktor.server.response.respondBytes -import io.ktor.server.response.respondText -import io.ktor.server.routing.get -import io.ktor.server.routing.head -import io.ktor.server.routing.routing -import io.ktor.http.HttpStatusCode -import io.ktor.http.HttpHeaders as KH -import io.ktor.utils.io.toByteArray -import java.io.File -import java.net.ServerSocket -import java.nio.file.Path -import kotlin.io.path.createTempDirectory -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.test.fail -import kotlinx.coroutines.runBlocking - -class ModelDownloaderTest { - - private val payload = ByteArray(8192) { (it and 0xff).toByte() } - - /** Поднимает fake-HTTP-server с поддержкой HEAD/GET/Range и возвращает `port`. */ - private fun startFakeServer(): FakeServer { - val port = ServerSocket(0).use { it.localPort } - val server = embeddedServer(CIO, port = port) { - routing { - head("/model.litertlm") { - call.response.headers.append(KH.AcceptRanges, "bytes") - call.response.headers.append(KH.ContentLength, payload.size.toString()) - call.respondText("") - } - get("/model.litertlm") { - val range = call.request.headers[KH.Range] - if (range == null) { - call.response.headers.append(KH.ContentLength, payload.size.toString()) - call.respondBytes(payload) - } else { - // Parse "bytes=N-" - val n = range.substringAfter("bytes=").substringBefore('-').toLong() - val slice = payload.copyOfRange(n.toInt(), payload.size) - call.response.status(HttpStatusCode.PartialContent) - call.response.headers.append(KH.ContentRange, "bytes $n-${payload.size - 1}/${payload.size}") - call.response.headers.append(KH.ContentLength, slice.size.toString()) - call.respondBytes(slice) - } - } - } - } - server.start(wait = false) - return FakeServer(server, port) - } - - private fun tmpFile(): File { - val dir: Path = createTempDirectory(prefix = "agentik-test-") - return dir.resolve("model.litertlm").toFile() - } - - @Test - fun `download writes full file when no part exists`() = runBlocking { - val fake = startFakeServer() - try { - val dest = tmpFile() - val dl = ModelDownloader() - val result = dl.download( - url = "http://127.0.0.1:${fake.port}/model.litertlm", - destPath = dest.absolutePath, - ) - assertEquals(payload.size.toLong(), result.bytes) - assertEquals(0L, result.resumedFrom) - assertContentEquals(payload, dest.readBytes()) - assertTrue(!File("${dest.absolutePath}.part").exists(), "part file should be cleaned up") - } finally { - fake.server.stop(100, 200) - } - } - - @Test - fun `download is no-op when final file already complete`() = runBlocking { - val fake = startFakeServer() - try { - val dest = tmpFile() - dest.writeBytes(payload) - val dl = ModelDownloader() - val result = dl.download( - url = "http://127.0.0.1:${fake.port}/model.litertlm", - destPath = dest.absolutePath, - ) - assertEquals(0L, result.bytes) - assertEquals(payload.size.toLong(), result.total) - assertContentEquals(payload, dest.readBytes()) - } finally { - fake.server.stop(100, 200) - } - } - - @Test - fun `download resumes from existing part file with Range request`() = runBlocking { - val fake = startFakeServer() - try { - val dest = tmpFile() - val partFile = File("${dest.absolutePath}.part") - val prefixSize = 4096 - partFile.writeBytes(payload.copyOfRange(0, prefixSize)) - - val dl = ModelDownloader() - val result = dl.download( - url = "http://127.0.0.1:${fake.port}/model.litertlm", - destPath = dest.absolutePath, - ) - assertEquals(prefixSize.toLong(), result.resumedFrom) - assertEquals(payload.size.toLong(), result.bytes) - assertContentEquals(payload, dest.readBytes()) - } finally { - fake.server.stop(100, 200) - } - } - - @Test - fun `download reports progress via callback`() = runBlocking { - val fake = startFakeServer() - try { - val dest = tmpFile() - val dl = ModelDownloader() - val reports = mutableListOf>() - dl.download( - url = "http://127.0.0.1:${fake.port}/model.litertlm", - destPath = dest.absolutePath, - progress = { d, t -> reports += d to t }, - ) - assertTrue(reports.isNotEmpty(), "progress must be reported at least once") - assertEquals(payload.size.toLong(), reports.last().first) - assertEquals(payload.size.toLong(), reports.last().second) - } finally { - fake.server.stop(100, 200) - } - } - - @Test - fun `download fails with clear message on HTTP 404`() = runBlocking { - // Spin up server that always 404s - val port = ServerSocket(0).use { it.localPort } - val server = embeddedServer(CIO, port = port) { - routing { - head("/missing") { call.respondText("", status = HttpStatusCode.NotFound) } - get("/missing") { call.respondText("", status = HttpStatusCode.NotFound) } - } - } - server.start(wait = false) - try { - val dest = tmpFile() - val dl = ModelDownloader() - try { - dl.download(url = "http://127.0.0.1:$port/missing", destPath = dest.absolutePath) - fail("expected failure on 404") - } catch (e: Exception) { - val msg = e.message ?: "" - assertTrue("404" in msg || "Not Found" in msg, - "error should mention HTTP 404, got: $msg") - } - } finally { - server.stop(100, 200) - } - } - - private data class FakeServer(val server: io.ktor.server.engine.EmbeddedServer<*, *>, val port: Int) -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpConfigTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpConfigTest.kt deleted file mode 100644 index b49eeff..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpConfigTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package pw.binom.agentik.standalone.mcp - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import pw.binom.agentik.mcp.bridge.McpConfig -import pw.binom.agentik.mcp.bridge.McpServerSpec - -class McpConfigTest { - - @Test - fun `fromEnv returns empty when env var unset`() { - val cfg = McpConfig.fromEnv { null } - assertTrue(cfg.isEmpty) - assertEquals(emptyList(), cfg.servers) - } - - @Test - fun `fromEnv returns empty when env var blank`() { - val cfg = McpConfig.fromEnv { "" } - assertTrue(cfg.isEmpty) - } - - @Test - fun `fromEnv returns empty when file missing`() { - val cfg = McpConfig.fromEnv { "/tmp/agentik-nonexistent-mcp-${System.nanoTime()}.json" } - assertTrue(cfg.isEmpty) - } - - @Test - fun `fromJson parses stdio server`() { - val json = """ - { "mcpServers": { - "fs": { "command": "npx", "args": ["-y", "fs-mcp"], "env": { "ROOT": "/work" } } - } } - """.trimIndent() - val cfg = McpConfig.fromJson(json) - assertEquals(1, cfg.servers.size) - val s = cfg.servers.single() as McpServerSpec.Stdio - assertEquals("fs", s.name) - assertEquals("npx", s.command) - assertEquals(listOf("-y", "fs-mcp"), s.args) - assertEquals(mapOf("ROOT" to "/work"), s.env) - } - - @Test - fun `fromJson parses http server with headers`() { - val json = """ - { "mcpServers": { - "remote": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer X" } } - } } - """.trimIndent() - val cfg = McpConfig.fromJson(json) - val s = cfg.servers.single() as McpServerSpec.Http - assertEquals("remote", s.name) - assertEquals("https://example.com/mcp", s.url) - assertEquals(mapOf("Authorization" to "Bearer X"), s.headers) - } - - @Test - fun `fromJson parses both stdio and http together`() { - val json = """ - { "mcpServers": { - "fs": { "command": "npx", "args": [] }, - "remote":{ "url": "https://example.com/mcp" } - } } - """.trimIndent() - val cfg = McpConfig.fromJson(json) - assertEquals(2, cfg.servers.size) - assertTrue(cfg.servers.any { it is McpServerSpec.Stdio && it.name == "fs" }) - assertTrue(cfg.servers.any { it is McpServerSpec.Http && it.name == "remote" }) - } - - @Test - fun `fromJson skips entries without command or url`() { - val json = """ - { "mcpServers": { - "broken": { "description": "no transport" } - } } - """.trimIndent() - val cfg = McpConfig.fromJson(json) - assertTrue(cfg.isEmpty) - } - - @Test - fun `empty returns empty config`() { - val cfg = McpConfig.empty() - assertTrue(cfg.isEmpty) - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt deleted file mode 100644 index 8eadffd..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package pw.binom.agentik.standalone.mcp - -import pw.binom.litert.LiteTool -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertSame -import kotlin.test.assertTrue -import pw.binom.agentik.toolsets.NamedTool -import pw.binom.agentik.mcp.bridge.McpConfig -import pw.binom.agentik.mcp.bridge.McpRegistry - -class McpRegistryTest { - - @Test - fun `empty config produces empty registry`() { - val registry = McpRegistry.fromConfig(McpConfig.empty()) - assertEquals(0, registry.allTools.size) - assertEquals(0, registry.connectedServerCount) - assertEquals(emptyList(), registry.namedTools) - registry.close() - } - - @Test - fun `empty servers list produces empty registry`() { - val registry = McpRegistry(servers = emptyList()) - assertEquals(0, registry.namedTools.size) - assertEquals(0, registry.allTools.size) - registry.close() - } - - @Test - fun `NamedTool holds name and tool reference`() { - val noop: LiteTool = object : LiteTool { - override fun describe(): String = "{}" - override fun invoke(arguments: String): String = "" - } - val nt = NamedTool(name = "server__echo", tool = noop) - assertEquals("server__echo", nt.name) - assertSame(noop, nt.tool) - assertEquals("{}", nt.tool.describe()) - } - - @Test - fun `registry close is idempotent`() { - val registry = McpRegistry.fromConfig(McpConfig.empty()) - registry.close() - registry.close() // should not throw - assertTrue(true) - } -} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt deleted file mode 100644 index 08270aa..0000000 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/persistence/PersistenceTest.kt +++ /dev/null @@ -1,372 +0,0 @@ -package pw.binom.agentik.standalone.persistence -import pw.binom.agentik.journal.MessageContext -import pw.binom.agentik.journal.MessageOrigin -import pw.binom.agentik.journal.ConversationRecord -import pw.binom.agentik.journal.MessageRecord -import pw.binom.agentik.journal.Content -import pw.binom.agentik.context.WorkingMemoryEntry - -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.runTest -import pw.binom.agentik.storage.ksqlite.KsqliteStores -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Instant - -class PersistenceTest { - - private lateinit var stores: KsqliteStores - - @BeforeTest - fun setup() { - stores = KsqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}") - } - - @AfterTest - fun tearDown() { - stores.close() - } - - @Test - fun `upsert + get conversation - roundtrip`() = runTest { - val now = Instant.fromEpochMilliseconds(1_700_000_000_000) - val rec = ConversationRecord( - id = "c1", - title = "Hello", - isTemporal = false, - createdAt = now, - updatedAt = now, - ) - stores.conversations.upsert(rec) - val got = stores.conversations.get("c1") - assertNotNull(got) - assertEquals(rec.id, got.id) - assertEquals(rec.title, got.title) - assertEquals(rec.isTemporal, got.isTemporal) - assertEquals(rec.createdAt, got.createdAt) - assertEquals(rec.updatedAt, got.updatedAt) - } - - @Test - fun `upsert overwrites existing record`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - stores.conversations.upsert( - ConversationRecord("c1", title = "A", isTemporal = false, createdAt = t0, updatedAt = t0), - ) - val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000) - stores.conversations.upsert( - ConversationRecord("c1", title = "B", isTemporal = true, createdAt = t0, updatedAt = t1), - ) - val got = stores.conversations.get("c1")!! - assertEquals("B", got.title) - assertTrue(got.isTemporal) - assertEquals(t1, got.updatedAt) - } - - @Test - fun `list returns conversations ordered by updated_at desc`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - repeat(3) { i -> - stores.conversations.upsert( - ConversationRecord( - id = "c$i", - title = null, - isTemporal = false, - createdAt = t0, - updatedAt = Instant.fromEpochMilliseconds(1_700_000_000_000 + i * 1000), - ), - ) - } - val list = stores.conversations.list(offset = 0, limit = 10) - assertEquals(listOf("c2", "c1", "c0"), list.map { it.id }) - } - - @Test - fun `delete cascades messages and working_memory`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - stores.conversations.upsert( - ConversationRecord("c1", null, false, t0, t0), - ) - stores.messages.append( - MessageRecord.UserMessage( - id = "m1", - conversationId = "c1", - content = listOf(Content.Text("hello")), - createdAt = t0, - ), - ) - stores.workingMemory.append( - conversationId = "c1", - entry = WorkingMemoryEntry.User( - sourceMessageId = "m1", - content = listOf(Content.Text("hello")), - ), - now = t0, - ) - 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.listFlow("c1", Instant.DISTANT_PAST).toList()) - assertEquals(emptyList(), stores.workingMemory.list("c1")) - } - - @Test - fun `message audit log - append and read back`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - stores.messages.append( - MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("hi")), t0), - ) - stores.messages.append( - MessageRecord.AssistantMessage("m2", "c1", listOf(Content.Text("yo")), t0), - ) - - val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList() - assertEquals(2, all.size) - assertEquals("m1", all[0].id) - assertEquals("m2", all[1].id) - assertTrue(all[0] is MessageRecord.UserMessage) - assertTrue(all[1] is MessageRecord.AssistantMessage) - assertEquals("hi", (all[0] as MessageRecord.UserMessage).content[0].let { - (it as Content.Text).body - }) - } - - @Test - fun `message after timestamp filter`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000) - stores.messages.append(MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("a")), t0)) - stores.messages.append(MessageRecord.UserMessage("m2", "c1", listOf(Content.Text("b")), t1)) - - val after = stores.messages.list("c1", after = t0, offset = 0, limit = 10) - assertEquals(1, after.size) - assertEquals("m2", after[0].id) - } - - @Test - fun `working memory - append + list preserves order`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000) - stores.workingMemory.append( - conversationId = "c1", - entry = WorkingMemoryEntry.User(sourceMessageId = "m1", content = listOf(Content.Text("hi"))), - now = t0, - ) - stores.workingMemory.append( - conversationId = "c1", - entry = WorkingMemoryEntry.Assistant(sourceMessageId = "m2", content = listOf(Content.Text("yo"))), - now = t1, - ) - val list = stores.workingMemory.list("c1") - assertEquals(2, list.size) - assertTrue(list[0].entry is WorkingMemoryEntry.User) - assertTrue(list[1].entry is WorkingMemoryEntry.Assistant) - } - - @Test - fun `working memory - compact without summary just drops tail`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0) - stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0) - stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0) - val rows = stores.workingMemory.list("c1") - // Drop начиная со второго хода (User m1) — должно остаться только User m1. - val dropFrom = rows[1].orderIdx - stores.workingMemory.compact(dropFrom, "c1", summaryText = null) - val after = stores.workingMemory.list("c1") - assertEquals(1, after.size) - assertTrue(after[0].entry is WorkingMemoryEntry.User) - } - - @Test - fun `working memory - compact with summary inserts Summary entry`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0) - stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0) - stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0) - val rows = stores.workingMemory.list("c1") - val dropFrom = rows[1].orderIdx - stores.workingMemory.compact(dropFrom, "c1", summaryText = "**Goal**: chat\n**Active**: at u2\n**Resolved**: a1") - val after = stores.workingMemory.list("c1") - assertEquals(2, after.size) - assertTrue(after[0].entry is WorkingMemoryEntry.User) - val summary = after[1].entry - assertIs(summary) - assertTrue(summary.text.startsWith("**Goal**")) - // order_idx должен быть > всех оставшихся - assertTrue(after[1].orderIdx > after[0].orderIdx) - // sourceMessageId у Summary всегда null - assertNull(after[1].sourceMessageId) - } - - @Test - fun `working memory - compact with blank summaryText behaves as drop`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0) - val rows = stores.workingMemory.list("c1") - stores.workingMemory.compact(rows[0].orderIdx + 1, "c1", summaryText = "") - val after = stores.workingMemory.list("c1") - assertEquals(1, after.size) - assertTrue(after[0].entry is WorkingMemoryEntry.User) - } - - @Test - fun `working memory - compact is atomic on other conversations`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0) - stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0) - stores.workingMemory.append("c2", WorkingMemoryEntry.User("m2", listOf(Content.Text("u2"))), t0) - stores.workingMemory.append("c2", WorkingMemoryEntry.Assistant("m3", listOf(Content.Text("a2"))), t0) - stores.workingMemory.compact(2, "c1", summaryText = "sum") - val c1 = stores.workingMemory.list("c1") - val c2 = stores.workingMemory.list("c2") - // c1: User m1 + Summary - assertEquals(2, c1.size) - assertTrue(c1[1].entry is WorkingMemoryEntry.Summary) - // c2 не тронут - assertEquals(2, c2.size) - assertTrue(c2[1].entry is WorkingMemoryEntry.Assistant) - } - - @Test - fun `rename updates title and bumps updated_at`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - stores.conversations.upsert(ConversationRecord("c1", null, false, t0, t0)) - val newTs = stores.conversations.rename("c1", "Renamed") - assertNotNull(newTs) - assertTrue(newTs > t0) - val got = stores.conversations.get("c1")!! - assertEquals("Renamed", got.title) - assertEquals(newTs, got.updatedAt) - } - - @Test - fun `rename can clear title`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - stores.conversations.upsert(ConversationRecord("c1", "Title", false, t0, t0)) - stores.conversations.rename("c1", null) - val got = stores.conversations.get("c1")!! - assertNull(got.title) - } - - @Test - fun `delete returns false when conversation does not exist`() = runTest { - assertFalse(stores.conversations.delete("nope")) - } - - @Test - fun `error record roundtrip through audit log`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - stores.messages.append( - MessageRecord.Error( - id = "e1", - conversationId = "c1", - message = "boom", - code = "E42", - createdAt = t0, - ), - ) - stores.messages.append( - MessageRecord.Error( - id = "e2", - conversationId = "c1", - message = "no code", - code = null, - createdAt = Instant.fromEpochMilliseconds(1_700_000_001_000), - ), - ) - val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList() - assertEquals(2, all.size) - val first = assertIs(all[0]) - assertEquals("e1", first.id) - assertEquals("boom", first.message) - assertEquals("E42", first.code) - assertEquals(t0, first.createdAt) - assertNull(assertIs(all[1]).code) - } - - @Test - fun `image content roundtrip through message payload`() = runTest { val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - val bytes = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) // PNG header - stores.messages.append( - MessageRecord.UserMessage( - id = "m1", - conversationId = "c1", - content = listOf(Content.Image(data = bytes, mime = "image/png")), - createdAt = t0, - ), - ) - 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)) - } - - @Test - fun `user message context roundtrips through SQLite`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - val ctx = MessageContext( - origin = MessageOrigin.EVENT, - description = "scheduled cron morning-briefing", - sourceId = "cron-42", - ) - stores.messages.append( - MessageRecord.UserMessage( - id = "m1", - conversationId = "c1", - content = listOf(Content.Text("wake up")), - createdAt = t0, - context = ctx, - ), - ) - val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList() - assertEquals(1, all.size) - val user = assertIs(all[0]) - assertEquals(ctx, user.context) - } - - @Test - fun `user message without context roundtrips with null context`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - stores.messages.append( - MessageRecord.UserMessage( - id = "m1", - conversationId = "c1", - content = listOf(Content.Text("regular user message")), - createdAt = t0, - ), - ) - val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList() - val user = assertIs(all[0]) - assertNull(user.context) - } - - @Test - fun `working memory user entry context roundtrips through SQLite`() = runTest { - val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000) - val ctx = MessageContext(origin = MessageOrigin.SYSTEM, description = "agent startup") - stores.workingMemory.append( - conversationId = "c1", - entry = WorkingMemoryEntry.User( - sourceMessageId = "m1", - content = listOf(Content.Text("boot")), - context = ctx, - ), - now = t0, - ) - val list = stores.workingMemory.list("c1") - assertEquals(1, list.size) - val user = assertIs(list[0].entry) - assertEquals(ctx, user.context) - } -}