Local message cache: instant start + offline reading (SQLDelight)
- Cache.sq / ChatDb: lanes, channels and last_lane persisted as JSON snapshots; sanitize on restore (Pending->Failed, streaming->false); ephemeral items (tool, system) skipped - Platform drivers via expect/actual: AndroidSqliteDriver (app db dir) / JdbcSqliteDriver (~/.iris/iris_cache.db) - IrisController: restore before connect, debounced (750ms) snapshot persistence, synchronous flush on dispose, clearAll on forget - History robustness: historyLoaded marked only when the response is processed (lost request/response retried on reconnect); events collector wrapped in try/catch; onHelloAck fast path so the history request fires on the WS thread instead of the starved state collector; frame-decode and history-load logging - Protocol: HistoryMessage.media nullable (defensive vs older gateways that sent "media": null) - Tests: ChatDbTest (jvmTest, JDBC in-memory), ChatStoreCacheTest, HistoryPayloadTest, HistoryWireTest (real captured 92KB response) - docs/10: §10.7 implemented schema, new §10.9 cache behavior
This commit is contained in:
1 parent
9a519e3c5a
commit
81f42ab761
18 files changed
+1040
-268
No files matched your search
@@ -0,0 +1,160 @@
|
||||
package iris.data
|
||||
|
||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
||||
import iris.db.IrisDatabase
|
||||
import iris.protocol.ChannelInfo
|
||||
import iris.protocol.RuntimeMeta
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/** Cache DB round-trip tests (JDBC in-memory SQLite; shared by both host targets). */
|
||||
class ChatDbTest {
|
||||
private fun newDb(): ChatDb {
|
||||
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
|
||||
IrisDatabase.Schema.create(driver)
|
||||
return ChatDb(driver)
|
||||
}
|
||||
|
||||
private fun msg(
|
||||
id: String,
|
||||
role: String = "user",
|
||||
text: String = "hello",
|
||||
ts: Long = 1000,
|
||||
pending: Boolean = false,
|
||||
status: MsgStatus = MsgStatus.Sent,
|
||||
streaming: Boolean = false,
|
||||
isSystem: Boolean = false,
|
||||
) = MessageItem(
|
||||
id = id,
|
||||
role = role,
|
||||
text = text,
|
||||
ts = ts,
|
||||
pending = pending,
|
||||
status = status,
|
||||
streaming = streaming,
|
||||
isSystem = isSystem,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun saveAndLoadLanesRoundTrip() {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
listOf(
|
||||
msg("m1", ts = 100),
|
||||
msg("m2", role = "assistant", text = "hi", ts = 200),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash"),
|
||||
),
|
||||
"android:default::thr_1" to listOf(msg("m3", ts = 300)),
|
||||
),
|
||||
)
|
||||
val loaded = db.loadLanes()
|
||||
assertEquals(setOf("android:default", "android:default::thr_1"), loaded.keys)
|
||||
// Tool cards are ephemeral — not persisted.
|
||||
assertEquals(listOf("m1", "m2"), loaded["android:default"]!!.map { it.id })
|
||||
assertEquals(listOf("m3"), loaded["android:default::thr_1"]!!.map { it.id })
|
||||
// Ordered by ts.
|
||||
assertEquals(100L, loaded["android:default"]!![0].ts)
|
||||
assertEquals(200L, loaded["android:default"]!![1].ts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveLanesReplacesPreviousSnapshot() {
|
||||
val db = newDb()
|
||||
db.saveLanes(mapOf("android:default" to listOf(msg("m1"), msg("m2"))))
|
||||
db.saveLanes(mapOf("android:default" to listOf(msg("m2"))))
|
||||
assertEquals(listOf("m2"), db.loadLanes()["android:default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreSanitizesMidFlightState() {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
listOf(
|
||||
msg("p1", status = MsgStatus.Pending, pending = true, ts = 0),
|
||||
msg("s1", role = "assistant", streaming = true, ts = 500),
|
||||
msg("r1", status = MsgStatus.Read, ts = 600),
|
||||
),
|
||||
),
|
||||
)
|
||||
val lane = db.loadLanes()["android:default"]!!
|
||||
// A pending send becomes failed (tap to retry); the gateway never
|
||||
// acknowledged it before the process died.
|
||||
assertEquals(MsgStatus.Failed, lane.first { it.id == "p1" }.status)
|
||||
assertEquals(false, lane.first { it.id == "p1" }.pending)
|
||||
// A streaming bubble is restored as finalized.
|
||||
assertEquals(false, lane.first { it.id == "s1" }.streaming)
|
||||
// Read status is preserved.
|
||||
assertEquals(MsgStatus.Read, lane.first { it.id == "r1" }.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun systemMessagesAreNotPersisted() {
|
||||
val db = newDb()
|
||||
db.saveLanes(mapOf("android:default" to listOf(msg("sys_1", role = "system", isSystem = true))))
|
||||
assertEquals(emptyMap<String, List<MessageItem>>(), db.loadLanes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaAndRuntimeSurviveRoundTrip() {
|
||||
val db = newDb()
|
||||
val item =
|
||||
MessageItem(
|
||||
id = "m1",
|
||||
role = "assistant",
|
||||
text = "look",
|
||||
ts = 100,
|
||||
runtime = RuntimeMeta(model = "gpt", latency = 1.5),
|
||||
media =
|
||||
listOf(
|
||||
MediaItem(
|
||||
mediaId = "med1",
|
||||
kind = "image",
|
||||
mime = "image/png",
|
||||
size = 42,
|
||||
filename = "a.png",
|
||||
localPath = "/tmp/a.png",
|
||||
),
|
||||
),
|
||||
)
|
||||
db.saveLanes(mapOf("android:default" to listOf(item)))
|
||||
val loaded = db.loadLanes()["android:default"]!!.first()
|
||||
assertEquals("gpt", loaded.runtime?.model)
|
||||
assertEquals("/tmp/a.png", loaded.media.first().localPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelsRoundTrip() {
|
||||
val db = newDb()
|
||||
db.saveChannels(
|
||||
listOf(
|
||||
ChannelInfo(chatId = "android:default", name = "General", isDefault = true),
|
||||
ChannelInfo(chatId = "android:chan_1", name = "Work"),
|
||||
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "android:chan_1"),
|
||||
),
|
||||
)
|
||||
val loaded = db.loadChannels()
|
||||
assertEquals(3, loaded.size)
|
||||
assertEquals("Work", loaded.first { it.chatId == "android:chan_1" }.name)
|
||||
assertEquals("thread", loaded.first { it.chatId == "thr_1" }.kind)
|
||||
assertEquals(true, loaded.first { it.chatId == "android:default" }.isDefault)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metaRoundTripAndClearAll() {
|
||||
val db = newDb()
|
||||
assertNull(db.metaGet("last_lane"))
|
||||
db.metaPut("last_lane", "android:chan_1")
|
||||
assertEquals("android:chan_1", db.metaGet("last_lane"))
|
||||
db.saveLanes(mapOf("android:default" to listOf(msg("m1"))))
|
||||
db.saveChannels(listOf(ChannelInfo(chatId = "android:default", name = "General")))
|
||||
db.clearAll()
|
||||
assertEquals(emptyMap<String, List<MessageItem>>(), db.loadLanes())
|
||||
assertEquals(emptyList<ChannelInfo>(), db.loadChannels())
|
||||
assertNull(db.metaGet("last_lane"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user