feat(client): client-side caching for conversation list via agent.outbox events

Adds `agent.conversationStore` (read-only view on `conversation` table) to
the :proto Agent interface, plus `agent.renameConversation(id, title?)`
command. Client-side cache in :client is built from a snapshot
(`remote.listFlow(0)` → `local.upsert(...)`) + live updates via
`outbox.agentEvents()` (Created/Deleted/Renamed/Touched).

Changes:
- :journal-api — split `ConversationStore` (read-only: get/list) and
  `MutableConversationStore` (CRUD: upsert/delete/rename/touch);
  `ConversationStore` gained `listFlow` (cold-flow paging via `list`).
- :outbox-api — `AgentEvent.Touched(date, id, updatedAt)` event so
  client cache stays fresh after `send()` (which bumps `updatedAt`).
- :proto.Agent — added `conversationStore: ConversationStore` property,
  added `renameConversation(id, title?): Instant?` command, removed
  `getConversations(offset, limit)` (now: `conversationStore.list(...)`).
- :server — `GET /conversations` now returns `List<ConversationRecord>`
  (lightweight metadata, no handle/image-support flags); `PATCH
  /conversations/{id}` uses `agent.renameConversation` and returns
  the updated `ConversationRecord`.
- :journal-inmemory — expanded targets to jvm+macos+linux+mingw (matches
  :client); moved `InMemoryMutableConversationStore` here from
  :storage-inmemory so :client can use it without pulling ios targets.
- :storage-inmemory — depends on :journal-inmemory.
- :storage-ksqlite — pre-staged rename `KsqliteConversationStore` →
  `KsqliteMutableConversationStore` to match the new interface split.
- :standalone — `ChatAgent` exposes `conversationStore` as a read-only
  view of its `mutableConversationStore`; emits `AgentEvent.Touched`
  after each `send()` (after `conversationStore.touch(id, ts)`).
- :client — new `HttpConversationStore` (read-only HTTP impl);
  `AgentikAgent` wraps the agent with `wrapWithLocalConversationCache`
  so the client sees an in-memory cache (snapshot + outbox events)
  instead of direct HTTP. Cache scope + HttpClient + background job
  all cancelled in `agent.close()`.
- :client/README — new «Кэш списка бесед» section with the
  `listFlow → upsert` / `agentEvents → apply` pattern and a note that
  `conversationStore` is read-only (writes only via Agent commands).

All 96 jvmTest tasks green.
This commit is contained in:
2026-09-22 05:21:01 +03:00
parent acb4ee6186
commit 5f0e0da361
29 changed files with 424 additions and 135 deletions
@@ -363,7 +363,7 @@ private fun runServer() {
val agent = ChatAgent(
id = "agentik",
conversationStore = sqliteStores.conversations,
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
@@ -1,11 +1,6 @@
package pw.binom.agentik.standalone.agent
import kotlin.time.Instant
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -18,7 +13,6 @@ import pw.binom.agentik.memory.MemorySystemGuidance
import pw.binom.agentik.proto.Agent as ProtoAgent
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Event as ProtoEvent
import pw.binom.agentik.outbox.MutableOutboxStore
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore
@@ -29,7 +23,7 @@ import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.Ids
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.reflection.Reflection
import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.journal.MutableJournalStore
@@ -65,7 +59,7 @@ import pw.binom.litert.LiteLlm
*/
class ChatAgent(
override val id: String,
private val conversationStore: ConversationStore,
private val mutableConversationStore: MutableConversationStore,
private val messageStore: MutableJournalStore,
private val workingMemoryStore: ContextStore,
private val reflectionStore: ReflectionStore,
@@ -239,6 +233,16 @@ class ChatAgent(
override val outbox: OutboxStore
get() = eventStore
/**
* Read-only view of [mutableConversationStore] для HTTP-фасада в `:server`
* (`GET /conversations` → список [ConversationRecord] для UI).
*
* Сужение с [MutableConversationStore] на [ConversationStore] тривиальна
* через interface-наследование (read-only projection).
*/
override val conversationStore: ConversationStore
get() = mutableConversationStore
/** Защищает карту живых диалогов. */
private val liveLock = Mutex()
private val live: MutableMap<String, ChatConversation> = HashMap()
@@ -262,12 +266,12 @@ class ChatAgent(
// и не переживают рестарт агента (см. Memory #3709).
if (!temp) {
runBlocking {
conversationStore.upsert(rec)
mutableConversationStore.upsert(rec)
}
}
val conv = ChatConversation(
record = rec,
conversationStore = conversationStore,
conversationStore = mutableConversationStore,
messageStore = messageStore,
workingMemoryStore = workingMemoryStore,
reflectionStore = reflectionStore,
@@ -302,7 +306,7 @@ class ChatAgent(
override suspend fun getConversation(id: String): ProtoConversation? {
liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it }
val rec = conversationStore.get(id) ?: return null
val rec = mutableConversationStore.get(id) ?: return null
return newConversation(rec).also {
liveLock.withLock { live[id] = it }
}
@@ -311,7 +315,7 @@ class ChatAgent(
override suspend fun deleteConversation(id: String): Boolean {
val conv = liveLock.withLock { live.remove(id) }
conv?.close()
val ok = conversationStore.delete(id)
val ok = mutableConversationStore.delete(id)
if (ok) {
val event = AgentEvent.Deleted(date = now(), id = id)
eventStore.append(CommonEvent.Agent(date = now(), event = event))
@@ -319,17 +323,16 @@ class ChatAgent(
return ok
}
override suspend fun getConversations(offset: Int, limit: Int): List<ProtoConversation> =
conversationStore.list(offset = offset, limit = limit).map { rec ->
liveLock.withLock { live[rec.id] }
?: newConversation(rec).also {
liveLock.withLock { live[rec.id] = it }
}
}
override suspend fun renameConversation(id: String, title: String?): Instant? {
val newUpdatedAt = mutableConversationStore.rename(id, title) ?: return null
val event = AgentEvent.Renamed(date = newUpdatedAt, id = id, title = title)
eventStore.append(CommonEvent.Agent(date = newUpdatedAt, event = event))
return newUpdatedAt
}
private fun newConversation(rec: ConversationRecord): ChatConversation = ChatConversation(
record = rec,
conversationStore = conversationStore,
conversationStore = mutableConversationStore,
messageStore = messageStore,
workingMemoryStore = workingMemoryStore,
reflectionStore = reflectionStore,
@@ -7,7 +7,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
@@ -31,7 +30,7 @@ import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.MessageContext
import pw.binom.agentik.journal.MessageOrigin
import pw.binom.agentik.journal.MessageRecord
@@ -54,7 +53,7 @@ import pw.binom.agentik.toolsets.NamedTool
class ConversationLoop(
record: ConversationRecord,
private val conversationStore: ConversationStore,
private val conversationStore: MutableConversationStore,
private val messageStore: MutableJournalStore,
private val workingMemoryStore: ContextStore,
private val reflectionStore: ReflectionStore?,
@@ -440,6 +439,18 @@ class ConversationLoop(
state.record = state.record.copy(updatedAt = assistantAt)
conversationStore.touch(id, assistantAt)
if (!state.isTemporal) {
eventStore.append(
pw.binom.agentik.outbox.CommonEvent.Agent(
date = assistantAt,
event = pw.binom.agentik.outbox.AgentEvent.Touched(
date = assistantAt,
id = id,
updatedAt = assistantAt,
),
)
)
}
// BackgroundScheduler is event-driven — подписан на BackgroundEventBus
// (compaction/lifecycle/tool-failure events). Никаких interval-based
@@ -62,7 +62,7 @@ class ChatAgentTest {
skills: SkillCatalog = SkillCatalog.EMPTY,
): ChatAgent = ChatAgent(
id = "agentik",
conversationStore = sqliteStores.conversations,
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
@@ -146,15 +146,32 @@ class ChatAgentTest {
}
@Test
fun `getConversations returns all stored persistent conversations`() = runTest {
fun `conversationStore list returns all stored persistent conversations`() = runTest {
val agent = newAgent()
agent.createConversation(temp = false)
agent.createConversation(temp = true)
val list = agent.getConversations(0, 10)
val list = agent.conversationStore.list(0, 10)
// temp-беседы не персистятся, в списке только persistent
assertEquals(1, list.size)
}
@Test
fun `renameConversation updates title and emits event`() = runTest {
val agent = newAgent()
val conv = agent.createConversation(temp = false)
val newTitle = "Renamed!"
val updatedAt = agent.renameConversation(conv.id, newTitle)
assertNotNull(updatedAt)
val rec = agent.conversationStore.get(conv.id)
assertEquals(newTitle, rec?.title)
}
@Test
fun `renameConversation returns null for unknown id`() = runTest {
val agent = newAgent()
assertNull(agent.renameConversation("nope", "x"))
}
@Test
fun `deleteConversation removes conversation and data`() = runTest {
val agent = newAgent()
@@ -458,7 +475,7 @@ class ChatAgentTest {
sqliteStores = KsqliteStores.open(dbPath)
val agent1 = ChatAgent(
id = "agentik",
conversationStore = sqliteStores.conversations,
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
@@ -478,7 +495,7 @@ class ChatAgentTest {
sqliteStores = KsqliteStores.open(dbPath)
val agent2 = ChatAgent(
id = "agentik",
conversationStore = sqliteStores.conversations,
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
@@ -500,7 +517,7 @@ class ChatAgentTest {
sqliteStores = KsqliteStores.open(dbPath)
val agent1 = ChatAgent(
id = "agentik",
conversationStore = sqliteStores.conversations,
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
@@ -518,7 +535,7 @@ class ChatAgentTest {
sqliteStores = KsqliteStores.open(dbPath)
val agent2 = ChatAgent(
id = "agentik",
conversationStore = sqliteStores.conversations,
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
@@ -43,7 +43,7 @@ class ChatAgentToolsetsTest {
val sqliteStores = KsqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}")
val agent = ChatAgent(
id = "test-agent",
conversationStore = sqliteStores.conversations,
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
@@ -63,7 +63,7 @@ class CompactionTest {
val reviewer = if (memoryStore != null) KeywordMdReviewer() else null
return ChatAgent(
id = "test",
conversationStore = sqliteStores.conversations,
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
@@ -76,7 +76,7 @@ class MemoryWiringTest {
contextCompactor: ContextCompactor = EchoCompactor,
): ChatAgent = ChatAgent(
id = "agentik",
conversationStore = sqliteStores.conversations,
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
@@ -115,7 +115,7 @@ class MemoryWiringTest {
val soulBody = "I am a helpful test persona. I always answer in one short line."
val agent = ChatAgent(
id = "agentik",
conversationStore = sqliteStores.conversations,
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
@@ -143,7 +143,7 @@ class MemoryWiringTest {
fun `soul body not added when null`() = runBlocking {
val agent = ChatAgent(
id = "agentik",
conversationStore = sqliteStores.conversations,
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,