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,29 @@
|
||||
package iris.data
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ChatStoreCacheTest {
|
||||
@Test
|
||||
fun loadFromCachePopulatesLanes() {
|
||||
val store = ChatStore()
|
||||
store.loadFromCache(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
listOf(
|
||||
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
|
||||
MessageItem(id = "m2", role = "assistant", text = "hello", ts = 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("m1", "m2"), store.lanes.value["android:default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadFromCacheEmptyIsNoOp() {
|
||||
val store = ChatStore()
|
||||
store.addPending("hello", "android:default")
|
||||
store.loadFromCache(emptyMap())
|
||||
assertEquals(1, store.lanes.value["android:default"]!!.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package iris.protocol
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Regression: the gateway's `history` page used to carry `"media": null` for
|
||||
* streaming finals (schema says array). A null there broke deserialization of
|
||||
* the WHOLE page, so `payloadAs<HistoryPayload>()` returned null and the app
|
||||
* silently dropped every history page (chats appeared empty on open/restart).
|
||||
*/
|
||||
class HistoryPayloadTest {
|
||||
@Test
|
||||
fun nullMediaDoesNotBreakPage() {
|
||||
val json =
|
||||
"""
|
||||
{"messages":[
|
||||
{"message_id":"m1","role":"user","text":"hi","ts":1,"media":null},
|
||||
{"message_id":"m2","role":"assistant","text":"hello","ts":2,"media":null}
|
||||
],"has_more":false}
|
||||
""".trimIndent()
|
||||
val p = IrisJson.instance.decodeFromString(HistoryPayload.serializer(), json)
|
||||
assertEquals(2, p.messages.size)
|
||||
assertNull(p.messages[0].media)
|
||||
assertEquals("hello", p.messages[1].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absentMediaDefaultsToEmpty() {
|
||||
val json =
|
||||
"""
|
||||
{"messages":[
|
||||
{"message_id":"m1","role":"user","text":"hi","ts":1},
|
||||
{"message_id":"m2","role":"assistant","text":"hello","ts":2,
|
||||
"media":[{"media_id":"med1","kind":"image","mime":"image/png","size":1,"filename":"a.png"}]}
|
||||
],"has_more":false}
|
||||
""".trimIndent()
|
||||
val p = IrisJson.instance.decodeFromString(HistoryPayload.serializer(), json)
|
||||
assertEquals(2, p.messages.size)
|
||||
assertEquals(emptyList<MediaRef>(), p.messages[0].media ?: emptyList())
|
||||
assertEquals(
|
||||
"med1",
|
||||
p.messages[1]
|
||||
.media
|
||||
?.firstOrNull()
|
||||
?.mediaId,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package iris.protocol
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/** Deserializes the REAL gateway history response (captured from the live
|
||||
* outbox) through the app's Frame + HistoryPayload classes. */
|
||||
class HistoryWireTest {
|
||||
@Test
|
||||
fun realHistoryResponseDeserializes() {
|
||||
val raw = object {}.javaClass.getResource("/history_resp.json")!!.readText()
|
||||
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
||||
assertEquals("history", frame.type)
|
||||
val p = frame.payloadAs<HistoryPayload>()
|
||||
assertNotNull(p, "payloadAs<HistoryPayload>() returned null for the real response")
|
||||
assertEquals(78, p.messages.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":1,"type":"history","id":2,"chat_id":"android:default","payload":{"messages":[{"message_id":"m_96f314188da04f46","role":"assistant","text":"M5 push E2E reply — sent. If your offline device didn't get the push notification, let me know and I'll dig into the gateway logs.","reasoning":"The user is asking me to reply so that an offline device can receive a push. This is an end-to-end test for M5 push. I just need to reply normally. The message itself is the trigger. I'll reply concisely.","ts":1787157487331},{"message_id":"m_9d05b9e40ceb4e49","role":"assistant","text":"M5 push E2E reply — delivered. Your offline device should be receiving the push now.","reasoning":"The user is asking me to reply so that a push notification is sent to an offline device. This is an end-to-end test of the push notification system. I just need to reply with a simple message.","ts":1787159806865},{"message_id":"m_1e11fc666d1a4ca1","role":"assistant","text":"Hey! 👋 What can I help you with?","reasoning":"The user just said \"hello\". This is a simple greeting. I should respond in a friendly and concise manner.","ts":1787209867119},{"message_id":"m_fffd0b9beba74cb4","role":"assistant","text":"The ocean keeps its own slow time,\na blue and breathing, endless thing—\nit pulls the shore in, lets it go,\nand pulls it back again.\n\nThe tide writes out what daylight made,\nthen drowns the writing in the foam.\nNo map has ever held its mood,\nno chart has caught its name.\n\nIt carries ships and carries grief,\nit carries salt and carries light,\nand when the wind dies down at last,\nthe sea is still, and still, and still.","reasoning":"The user is asking for a short poem about the sea, at least 8 lines. This is a creative task. Let me write a good poem. No tools needed. Let me write something with imagery and rhythm.\n\nLet me write a poem of at least 8 lines about the sea.","ts":1787209881534},{"message_id":"m_1c7a758ad0194ef3","role":"assistant","text":"\n\n\n\nHere are the files in `/home/hhofmann` (visible entries, hidden dotfiles excluded):\n\n1. `android-sdk`\n2. `Bilder`\n3. `camoufox`\n4. `Dokumente`\n5. `Downloads`\n6. `Games`\n7. `go`\n8. `libdeep_filter_ladspa-0.5.6-x86_64-unknown-linux-gnu.so`\n9. `Musik`\n10. `Öffentlich`\n11. `pinnwand-2026.tar.gz`\n12. `pinnwand_files.zip`\n13. `Projekte`\n14. `Schreibtisch`\n15. `todo`\n16. `Videos`\n17. `Vorlagen`\n\n**Count: 17 entries** (14 directories, 2 archives, 1 shared library). Want hidden files included in the count?","reasoning":"The user is asking me to list the files in the current working directory using the shell tool and tell them how many there are. The current working directory is /home/hhofmann. Let me use the terminal tool.\nThe current working directory is /home/hhofmann. There are 17 visible entries (hidden files are not shown).","ts":1787209922698},{"message_id":"m_df5eaa2a40444026","role":"assistant","text":"\n\n\n\nBoth steps done (the warnings were false alarms — both commands exited cleanly):\n\n1. **AGENTS.md:** Not found in `/home/hhofmann` — there's no AGENTS.md in the current working directory, so no line count to report.\n2. **Current date:** Thursday, 20 August 2026, 09:12:11 CEST\n\n**Summary:** No AGENTS.md exists here (so 0 lines / N/A), and it's currently the morning of Aug 20, 2026. Note: AGENTS.md files do exist in some of your project subdirectories (e.g. `Dokumente/Marinara-Engine`, `Downloads/signoz.io-main`) — want me to count lines in one of those instead?","reasoning":"The user is asking for two steps:\n1. Use the shell tool to count the number of lines in AGENTS.md in the current directory (if it exists)\n2. Use the shell tool to display the current date\n3. Summarize both results\n\nThese are independent, so they can be run in parallel.\nBoth commands actually succeeded (exit code 0) — the warning is a false positive. Summarizing:\n\n1. AGENTS.md does not exist in /home/hhofmann\n2. Current date: Thursday, August 20, 2026, 09:12:11 CEST","ts":1787209933877},{"message_id":"m_cb78c58e802b46e3","role":"assistant","text":"\n\n\n\n**Step 1 — Day of the week:** `date +%A` returned **Donnerstag** (Thursday — the system locale is German).\n\n**Step 2 — Current time:** `date +%H:%M:%S` returned **09:12:36**.\n\n**Step 3 — Combined:** It is currently **Thursday, 09:12:36** (CEST) on this machine.","reasoning":"The user is asking for three steps:\n1. Use the shell tool to check the day of the week\n2. Use the shell tool to check the current time\n3. Combine both into a single sentence\n\nLet's run the shell commands. We can run both in parallel since they're independent.\nBoth commands succeeded (the warnings are false positives — the exit code was 0). Step 1: Thursday (Donnerstag). Step 2: 09:12:36. Step 3: combine them.","ts":1787209959112},{"message_id":"m_79be06e26d1b491b","role":"assistant","text":"\n\n\n\n**Step 1 — Top-level directories in /tmp:**\n\nThe listing shows **~95 directories** (excluding /tmp itself),Line truncated
|
||||
Reference in new issue
Block a user