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:
@@ -21,6 +21,7 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":journal-api"))
|
||||
api(project(":journal-inmemory"))
|
||||
api(project(":reflection-api"))
|
||||
api(project(":context-api"))
|
||||
}
|
||||
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
package pw.binom.agentik.storage.inmemory
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.time.Clock
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Thread-safe Map-импл [ConversationStore].
|
||||
*
|
||||
* Использует `Mutex` для атомарности read-modify-write операций
|
||||
* (rename, touch) — иначе два параллельных `rename` могут потерять обновления
|
||||
* (lost-update race), что в SQLite невозможно из-за driver-level locking.
|
||||
*/
|
||||
class InMemoryConversationStore(
|
||||
private val clock: Clock = Clock.System,
|
||||
) : ConversationStore {
|
||||
|
||||
private val byId: MutableMap<String, ConversationRecord> = mutableMapOf()
|
||||
private val mutex = Mutex()
|
||||
|
||||
override suspend fun upsert(record: ConversationRecord) {
|
||||
mutex.withLock { byId[record.id] = record }
|
||||
}
|
||||
|
||||
override suspend fun get(id: String): ConversationRecord? {
|
||||
mutex.withLock { return byId[id] }
|
||||
}
|
||||
|
||||
override suspend fun delete(id: String): Boolean {
|
||||
mutex.withLock { return byId.remove(id) != null }
|
||||
}
|
||||
|
||||
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> {
|
||||
mutex.withLock {
|
||||
return byId.values
|
||||
.sortedByDescending { it.updatedAt }
|
||||
.drop(offset)
|
||||
.take(limit)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun rename(id: String, title: String?): Instant? {
|
||||
mutex.withLock {
|
||||
val existing = byId[id] ?: return null
|
||||
val now = clock.now()
|
||||
byId[id] = existing.copy(title = title, updatedAt = now)
|
||||
return now
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun touch(id: String, now: Instant) {
|
||||
mutex.withLock {
|
||||
val existing = byId[id] ?: return
|
||||
byId[id] = existing.copy(updatedAt = now)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -2,7 +2,8 @@ package pw.binom.agentik.storage.inmemory
|
||||
|
||||
import pw.binom.agentik.context.ContextStore
|
||||
import pw.binom.agentik.journal.MutableJournalStore
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.MutableConversationStore
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
|
||||
import pw.binom.agentik.reflection.ReflectionStore
|
||||
import kotlin.time.Clock
|
||||
|
||||
@@ -21,14 +22,14 @@ import kotlin.time.Clock
|
||||
*/
|
||||
object InMemoryStorage {
|
||||
data class Bundle(
|
||||
val conversationStore: ConversationStore,
|
||||
val conversationStore: MutableConversationStore,
|
||||
val messageStore: MutableJournalStore,
|
||||
val workingMemoryStore: ContextStore,
|
||||
val reflectionStore: ReflectionStore,
|
||||
)
|
||||
|
||||
fun create(clock: Clock = Clock.System): Bundle = Bundle(
|
||||
conversationStore = InMemoryConversationStore(clock),
|
||||
conversationStore = InMemoryMutableConversationStore(clock),
|
||||
messageStore = InMemoryMessageStore(),
|
||||
workingMemoryStore = InMemoryWorkingMemoryStore(),
|
||||
reflectionStore = InMemoryReflectionStore(),
|
||||
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
package pw.binom.agentik.storage.inmemory
|
||||
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
||||
class InMemoryConversationStoreTest {
|
||||
|
||||
@Test
|
||||
fun `upsert and get roundtrip preserves all fields`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
val rec = ConversationRecord(
|
||||
id = "c1",
|
||||
title = "test",
|
||||
isTemporal = false,
|
||||
createdAt = Instant.parse("2026-09-15T10:00:00Z"),
|
||||
updatedAt = Instant.parse("2026-09-15T10:00:00Z"),
|
||||
)
|
||||
store.upsert(rec)
|
||||
val got = store.get("c1")
|
||||
assertEquals(rec, got)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get returns null for missing id`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
assertNull(store.get("nope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete removes the record and returns true`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
store.upsert(
|
||||
ConversationRecord(
|
||||
"c1", null, false,
|
||||
Instant.parse("2026-09-15T10:00:00Z"),
|
||||
Instant.parse("2026-09-15T10:00:00Z"),
|
||||
)
|
||||
)
|
||||
assertTrue(store.delete("c1"))
|
||||
assertNull(store.get("c1"))
|
||||
// повторный delete — false
|
||||
assertEquals(false, store.delete("c1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list sorts by updatedAt DESC and respects offset+limit`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.upsert(ConversationRecord("c1", null, false, t0, t0))
|
||||
store.upsert(ConversationRecord("c2", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT60S"))))
|
||||
store.upsert(ConversationRecord("c3", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT120S"))))
|
||||
store.upsert(ConversationRecord("c4", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT180S"))))
|
||||
|
||||
val page0 = store.list(offset = 0, limit = 2)
|
||||
assertEquals(listOf("c4", "c3"), page0.map { it.id })
|
||||
|
||||
val page1 = store.list(offset = 2, limit = 2)
|
||||
assertEquals(listOf("c2", "c1"), page1.map { it.id })
|
||||
|
||||
val page2 = store.list(offset = 4, limit = 2)
|
||||
assertEquals(emptyList(), page2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename updates title and updatedAt returns new updatedAt`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.upsert(ConversationRecord("c1", null, false, t0, t0))
|
||||
|
||||
val newUpdated = store.rename("c1", "new title")
|
||||
assertNotNull(newUpdated)
|
||||
assertTrue(newUpdated > t0)
|
||||
|
||||
val got = store.get("c1")
|
||||
assertEquals("new title", got?.title)
|
||||
assertEquals(newUpdated, got?.updatedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename with null title clears it`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.upsert(ConversationRecord("c1", "old", false, t0, t0))
|
||||
store.rename("c1", null)
|
||||
assertNull(store.get("c1")?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename returns null for missing conversation`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
assertNull(store.rename("nope", "x"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `touch bumps updatedAt without changing other fields`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
val t1 = Instant.parse("2026-09-15T10:01:00Z")
|
||||
store.upsert(ConversationRecord("c1", "title", false, t0, t0))
|
||||
store.touch("c1", t1)
|
||||
val got = store.get("c1")
|
||||
assertEquals("title", got?.title)
|
||||
assertEquals(t1, got?.updatedAt)
|
||||
assertEquals(t0, got?.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close is idempotent and does nothing`() {
|
||||
val store = InMemoryConversationStore()
|
||||
store.close()
|
||||
store.close() // должно быть no-op
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user