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:
ARIA committed 2026-08-21 20:56:20 +02:00
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"))
}
}