feat: история общения (SQLite) + Koog LLM-клиент в app-phone

SQLite (голый SQLiteOpenHelper, без ORM — как решили):
- sessions (id, title, created_at, updated_at) + messages (role, content,
  tool_call_id, tool_calls JSON, attachments JSON, created_at) по утверждённой
  схеме; FK-каскад; CHECK на роли.
- ChatDb + ChatDao (createSession/listSessions/rename/touch/delete, addMessage,
  messagesForSession), доступ через PhoneApp.chatDao.
- Unit: ModelsTest (роли, JSON tool_calls/attachments). androidTest:
  ChatDaoTest — CRUD-круг + каскад — OK (3 tests) на эмуляторе.

Koog (JetBrains) — базовый LLM-клиент, без агентного слоя:
- ai.koog:prompt-executor-openai-client:1.1.1 + http-client-okhttp
- LlmClient: OpenAILLMClient(apiKey, baseUrl=llm.binom.pw) → chat(история)
- BuildConfig LLM_API_KEY/LLM_MODEL из local.properties (вне git); llm_debug
  smoke (--ez llm_debug true) — проверен на эмуляторе: реальный ответ от
  llm.binom.pw (deepseek-v4-flash).
- ЗАГЛУШКА Coroutines_jvmKt: дефект публикации Koog 1.1.1 — utils-android не
  включает jvm-класс, который зовёт OkHttpKoogHttpClient (без неё
  NoClassDefFoundError на первом вызове). Сигнатура сверена с utils-jvm.
  Убрать после фикса JetBrains.
This commit is contained in:
Hermes Agent
2026-08-20 11:52:50 +03:00
parent 3ed63012f4
commit d9d34e9277
12 changed files with 591 additions and 0 deletions
@@ -0,0 +1,88 @@
package pw.binom.viewmate.phone.agent
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ChatDaoTest {
private lateinit var dao: ChatDao
private val context: Context = ApplicationProvider.getApplicationContext()
@Before
fun setUp() {
// чистый старт: БД пересоздаётся на каждый тест
context.deleteDatabase("assistant.db")
dao = ChatDao(ChatDb(context))
}
@After
fun tearDown() {
context.deleteDatabase("assistant.db")
}
@Test
fun sessionLifecycleWithMessages() {
val sessionId = dao.createSession()
assertTrue(sessionId > 0)
val userMsgId = dao.addMessage(sessionId, ChatRole.USER, "включи Матрицу")
val assistantMsgId = dao.addMessage(
sessionId,
ChatRole.ASSISTANT,
"",
toolCalls = listOf(ToolCall("c1", "play_movie", "{}")),
)
val toolMsgId = dao.addMessage(sessionId, ChatRole.TOOL, "", toolCallId = "c1")
// порядок по id и совпадение ролей/полей
val messages = dao.messagesForSession(sessionId)
assertEquals(listOf(userMsgId, assistantMsgId, toolMsgId), messages.map { it.id })
assertEquals(3, messages.size)
assertEquals(ChatRole.USER, messages[0].role)
assertEquals("включи Матрицу", messages[0].content)
assertTrue(messages[0].toolCalls.isEmpty())
assertEquals(ChatRole.ASSISTANT, messages[1].role)
assertEquals(listOf(ToolCall("c1", "play_movie", "{}")), messages[1].toolCalls)
assertEquals(ChatRole.TOOL, messages[2].role)
assertEquals("c1", messages[2].toolCallId)
// список сессий: созданная есть
assertEquals(sessionId, dao.listSessions().single().id)
// каскадное удаление сообщений вместе с сессией
dao.deleteSession(sessionId)
assertTrue(dao.messagesForSession(sessionId).isEmpty())
assertTrue(dao.listSessions().none { it.id == sessionId })
}
@Test
fun addMessageTouchesSessionUpdatedAt() {
val sessionId = dao.createSession()
val before = dao.listSessions().single().updatedAt
dao.addMessage(sessionId, ChatRole.USER, "привет")
val after = dao.listSessions().single().updatedAt
assertTrue("updated_at должен обновиться после addMessage", after >= before)
}
@Test
fun renameSessionChangesTitle() {
val sessionId = dao.createSession("первая")
dao.renameSession(sessionId, "Матрица")
val session = dao.listSessions().single()
assertEquals("Матрица", session.title)
}
}