git.iko.soy/opencode-android
on master help sign in
Wire session rename UI, add SSE reducer tests, fix connection state, and enable R8 shrinking on release builds
e37d1bbiko
README.md+2
@@ -32,6 +32,7 @@ Milestones M0–M7 complete:
32 dynamic "Thinking…" → "Thoughts" reasoning label; back-press guard while an agent is running;
32 dynamic "Thinking…" → "Thoughts" reasoning label; back-press guard while an agent is running;
33 polished empty states; richer **Settings** (Dynamic Color / Material You toggle, version info,
33 polished empty states; richer **Settings** (Dynamic Color / Material You toggle, version info,
34 manage-servers link); haptic feedback; and a file viewer with line numbers, copy, and share.
34 manage-servers link); haptic feedback; and a file viewer with line numbers, copy, and share.
35+ The session list also supports **renaming** a session (`PATCH /session/:id`).
35
36
36## Architecture
37## Architecture
37
38
@@ -54,6 +55,7 @@ Source map: `data/network` (Ktor, SSE, JSON), `data/model` (wire models), `data/
54```bash
55```bash
55nix develop # JDK 17, Gradle 8.14.4, Android SDK 35, aapt2 override
56nix develop # JDK 17, Gradle 8.14.4, Android SDK 35, aapt2 override
56./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
57./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
58+./gradlew assembleRelease # R8-minified + resource-shrunk release APK
57./gradlew testDebugUnitTest # JVM tests for the JSON/serialization layer
59./gradlew testDebugUnitTest # JVM tests for the JSON/serialization layer
58```
60```
59
61
app/build.gradle.kts+2−1
@@ -20,7 +20,8 @@ android {
20
20
21 buildTypes {
21 buildTypes {
22 release {
22 release {
23− isMinifyEnabled = false
23+ isMinifyEnabled = true
24+ isShrinkResources = true
24 proguardFiles(
25 proguardFiles(
25 getDefaultProguardFile("proguard-android-optimize.txt"),
26 getDefaultProguardFile("proguard-android-optimize.txt"),
26 "proguard-rules.pro",
27 "proguard-rules.pro",
app/proguard-rules.pro+23−2
@@ -1,5 +1,6 @@
1−# kotlinx.serialization keeps generated serializers via the plugin; standard rules below.
1+# --- kotlinx.serialization ---
2+# The plugin generates serializers; keep them and the $$serializer companion members.
2−-keepattributes *Annotation*, InnerClasses
3+-keepattributes *Annotation*, InnerClasses, Signature, Exceptions
3-dontnote kotlinx.serialization.**
4-dontnote kotlinx.serialization.**
4-keepclassmembers class soy.iko.opencode.** {
5-keepclassmembers class soy.iko.opencode.** {
5 *** Companion;
6 *** Companion;
@@ -7,3 +8,23 @@
7-keepclasseswithmembers class soy.iko.opencode.** {
8-keepclasseswithmembers class soy.iko.opencode.** {
8 kotlinx.serialization.KSerializer serializer(...);
9 kotlinx.serialization.KSerializer serializer(...);
9}
10}
11+# Keep @Serializable classes themselves so their generated serializers can reference them.
12+-keep,includedescriptorclasses class soy.iko.opencode.**$$serializer { *; }
13+-keepclassmembers class soy.iko.opencode.** {
14+ *** Companion;
15+}
16+# Keep the serializable model classes (reflection-free serializer lookup needs the class).
17+-keep @kotlinx.serialization.Serializable class soy.iko.opencode.data.model.** { *; }
18+
19+# --- Ktor / OkHttp (HTTP client + SSE) ---
20+-dontwarn io.ktor.**
21+-dontwarn okhttp3.**
22+-dontwarn okio.**
23+-dontwarn org.slf4j.**
24+-keep class io.ktor.** { *; }
25+-keep class okhttp3.** { *; }
26+-keep class okio.** { *; }
27+
28+# --- androidx.security.crypto pulls in Tink, which references compile-only
29+# com.google.errorprone annotations. They aren't on the runtime classpath.
30+-dontwarn com.google.errorprone.annotations.**
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+7−4
@@ -1,9 +1,9 @@
1package soy.iko.opencode.data.network
1package soy.iko.opencode.data.network
2
2
3import soy.iko.opencode.data.model.BusEvent
3import soy.iko.opencode.data.model.BusEvent
4−import soy.iko.opencode.data.model.ServerConnected
5import io.ktor.client.HttpClient
4import io.ktor.client.HttpClient
6import io.ktor.client.plugins.sse.sse
5import io.ktor.client.plugins.sse.sse
6+import kotlinx.coroutines.CancellationException
7import kotlinx.coroutines.CoroutineScope
7import kotlinx.coroutines.CoroutineScope
8import kotlinx.coroutines.delay
8import kotlinx.coroutines.delay
9import kotlinx.coroutines.flow.Flow
9import kotlinx.coroutines.flow.Flow
@@ -51,15 +51,18 @@ class EventStreamClient(
51 val data = sse.data ?: return@collect
51 val data = sse.data ?: return@collect
52 val event = runCatching { OpencodeJson.decodeFromString(BusEvent.serializer(), data) }
52 val event = runCatching { OpencodeJson.decodeFromString(BusEvent.serializer(), data) }
53 .getOrNull() ?: return@collect
53 .getOrNull() ?: return@collect
54− if (event is ServerConnected) {
54+ // Any successfully decoded event means the stream is live;
55+ // don't wait for a ServerConnected event to show "Connected".
56+ if (_state.value != ConnectionState.Connected) {
55 _state.value = ConnectionState.Connected
57 _state.value = ConnectionState.Connected
56 }
58 }
57 send(event)
59 send(event)
58 }
60 }
59 }
61 }
62+ } catch (c: CancellationException) {
63+ throw c
60 } catch (t: Throwable) {
64 } catch (t: Throwable) {
61− if (!isActive) throw t
62− // fall through to reconnect
65+ // transient error — fall through to reconnect
63 }
66 }
64 _state.value = ConnectionState.Disconnected
67 _state.value = ConnectionState.Disconnected
65 if (!isActive) break
68 if (!isActive) break
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+70−67
@@ -69,89 +69,92 @@ class SessionRepository(
69 job.join()
69 job.join()
70 }
70 }
71
71
72− /** In-memory reduction state for one observed session. Not thread-safe; guard with a Mutex. */
72+ companion object {
73− private class MessageStore {
73+ /** Convenience: is this event a run-completion signal for [sessionId]? */
74− // messageId -> (info + parts), insertion-ordered.
74+ fun isIdle(event: BusEvent, sessionId: String): Boolean =
75− private val messages = LinkedHashMap<String, MessageWithParts>()
75+ event is SessionIdle && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
76
76
77− fun snapshot(): List<MessageWithParts> = messages.values.toList()
77+ fun isError(event: BusEvent, sessionId: String): Boolean =
78+ event is SessionError && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
79+ }
80+}
78
81
79− fun seed(initial: List<MessageWithParts>) {
82+/**
80− for (m in initial) messages[m.info.id] = m
83+ * In-memory reduction state for one observed session. Not thread-safe; guard with a Mutex.
81− }
84+ * Exposed as `internal` so the reducer logic can be unit-tested directly.
85+ */
86+internal class MessageStore {
87+ // messageId -> (info + parts), insertion-ordered.
88+ private val messages = LinkedHashMap<String, MessageWithParts>()
82
89
83− /** Returns true if the state changed (and a new snapshot should be published). */
90+ fun snapshot(): List<MessageWithParts> = messages.values.toList()
84− fun reduce(sessionId: String, event: BusEvent): Boolean {
85− return when (event) {
86− is MessageUpdated -> {
87− val info = event.properties.info
88− if (info.sessionID != sessionId) {
89− false
90− } else {
91− val existing = messages[info.id]
92− messages[info.id] = existing?.copy(info = info) ?: MessageWithParts(info)
93− true
94− }
95− }
96
91
97− is MessagePartUpdated -> {
92+ fun seed(initial: List<MessageWithParts>) {
98− val part = event.properties.part
93+ for (m in initial) messages[m.info.id] = m
99− val messageId = part.messageID ?: event.properties.messageID
100− val partSession = part.sessionID ?: event.properties.sessionID
101− if (messageId == null || (partSession != null && partSession != sessionId)) {
102− false
103− } else {
104− upsertPart(messageId, part)
105− }
106− }
94+ }
107
95
108− is MessagePartRemoved -> {
96+ /** Returns true if the state changed (and a new snapshot should be published). */
97+ fun reduce(sessionId: String, event: BusEvent): Boolean {
98+ return when (event) {
99+ is MessageUpdated -> {
109− val messageId = event.properties.messageID
100+ val info = event.properties.info
110− val partId = event.properties.partID
101+ if (info.sessionID != sessionId) {
111− if (messageId == null || partId == null) false else removePart(messageId, partId)
102+ false
103+ } else {
104+ val existing = messages[info.id]
105+ messages[info.id] = existing?.copy(info = info) ?: MessageWithParts(info)
106+ true
112 }
107 }
108+ }
113
109
114− is MessageRemoved -> {
110+ is MessagePartUpdated -> {
111+ val part = event.properties.part
115− val id = event.properties.messageID
112+ val messageId = part.messageID ?: event.properties.messageID
116− if (id == null) false else messages.remove(id) != null
113+ val partSession = part.sessionID ?: event.properties.sessionID
114+ if (messageId == null || (partSession != null && partSession != sessionId)) {
115+ false
116+ } else {
117+ upsertPart(messageId, part)
117 }
118 }
119+ }
118
120
119− else -> false
121+ is MessagePartRemoved -> {
122+ val messageId = event.properties.messageID
123+ val partId = event.properties.partID
124+ if (messageId == null || partId == null) false else removePart(messageId, partId)
120 }
125 }
121− }
122
126
123− private fun upsertPart(messageId: String, part: Part): Boolean {
127+ is MessageRemoved -> {
124− val current = messages[messageId]
128+ val id = event.properties.messageID
125− val parts = current?.parts.orEmpty()
129+ if (id == null) false else messages.remove(id) != null
126− val idx = parts.indexOfFirst { it.id == part.id }
127− val newParts = if (idx >= 0) {
128− parts.toMutableList().also { it[idx] = part }
129− } else {
130− parts + part
131 }
130 }
132− messages[messageId] = current?.copy(parts = newParts)
133− ?: MessageWithParts(
134− info = AssistantMessage(id = messageId, sessionID = part.sessionID ?: ""),
135− parts = newParts,
136− )
137− return true
138− }
139
131
140− private fun removePart(messageId: String, partId: String): Boolean {
132+ else -> false
141− val current = messages[messageId] ?: return false
142− val newParts = current.parts.filterNot { it.id == partId }
143− if (newParts.size == current.parts.size) return false
144− messages[messageId] = current.copy(parts = newParts)
145− return true
146 }
133 }
147 }
134 }
148
135
149− companion object {
136+ private fun upsertPart(messageId: String, part: Part): Boolean {
150− /** Convenience: is this event a run-completion signal for [sessionId]? */
137+ val current = messages[messageId]
151− fun isIdle(event: BusEvent, sessionId: String): Boolean =
138+ val parts = current?.parts.orEmpty()
152− event is SessionIdle && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
139+ val idx = parts.indexOfFirst { it.id == part.id }
140+ val newParts = if (idx >= 0) {
141+ parts.toMutableList().also { it[idx] = part }
142+ } else {
143+ parts + part
144+ }
145+ messages[messageId] = current?.copy(parts = newParts)
146+ ?: MessageWithParts(
147+ info = AssistantMessage(id = messageId, sessionID = part.sessionID ?: ""),
148+ parts = newParts,
149+ )
150+ return true
151+ }
153
152
154− fun isError(event: BusEvent, sessionId: String): Boolean =
153+ private fun removePart(messageId: String, partId: String): Boolean {
155− event is SessionError && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
154+ val current = messages[messageId] ?: return false
155+ val newParts = current.parts.filterNot { it.id == partId }
156+ if (newParts.size == current.parts.size) return false
157+ messages[messageId] = current.copy(parts = newParts)
158+ return true
156 }
159 }
157}
160}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+49
@@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Add
19import androidx.compose.material.icons.filled.ArrowDropDown
19import androidx.compose.material.icons.filled.ArrowDropDown
20import androidx.compose.material.icons.filled.ChatBubbleOutline
20import androidx.compose.material.icons.filled.ChatBubbleOutline
21import androidx.compose.material.icons.filled.Delete
21import androidx.compose.material.icons.filled.Delete
22+import androidx.compose.material.icons.filled.Edit
22import androidx.compose.material.icons.filled.Folder
23import androidx.compose.material.icons.filled.Folder
23import androidx.compose.material.icons.filled.Refresh
24import androidx.compose.material.icons.filled.Refresh
24import androidx.compose.material.icons.filled.Search
25import androidx.compose.material.icons.filled.Search
@@ -73,6 +74,7 @@ fun SessionListScreen(
73 val haptics = LocalHapticFeedback.current
74 val haptics = LocalHapticFeedback.current
74 var showServerMenu by remember { mutableStateOf(false) }
75 var showServerMenu by remember { mutableStateOf(false) }
75 var pendingDelete by remember { mutableStateOf<Session?>(null) }
76 var pendingDelete by remember { mutableStateOf<Session?>(null) }
77+ var pendingRename by remember { mutableStateOf<Session?>(null) }
76
78
77 Scaffold(
79 Scaffold(
78 topBar = {
80 topBar = {
@@ -188,6 +190,7 @@ fun SessionListScreen(
188 session = session,
190 session = session,
189 preview = state.previews[session.id],
191 preview = state.previews[session.id],
190 onClick = { onOpenSession(session.id) },
192 onClick = { onOpenSession(session.id) },
193+ onRename = { pendingRename = session },
191 onDelete = { pendingDelete = session },
194 onDelete = { pendingDelete = session },
192 )
195 )
193 }
196 }
@@ -215,6 +218,48 @@ fun SessionListScreen(
215 },
218 },
216 )
219 )
217 }
220 }
221+
222+ pendingRename?.let { session ->
223+ var title by remember { mutableStateOf(session.title ?: "") }
224+ RenameSessionDialog(
225+ title = title,
226+ onTitleChange = { title = it },
227+ onConfirm = {
228+ val newName = title.trim()
229+ pendingRename = null
230+ if (newName.isNotEmpty() && newName != session.title) vm.renameSession(session, newName)
231+ },
232+ onDismiss = { pendingRename = null },
233+ )
234+ }
235+}
236+
237+@Composable
238+private fun RenameSessionDialog(
239+ title: String,
240+ onTitleChange: (String) -> Unit,
241+ onConfirm: () -> Unit,
242+ onDismiss: () -> Unit,
243+) {
244+ AlertDialog(
245+ onDismissRequest = onDismiss,
246+ title = { Text("Rename session") },
247+ text = {
248+ OutlinedTextField(
249+ value = title,
250+ onValueChange = onTitleChange,
251+ modifier = Modifier.fillMaxWidth(),
252+ placeholder = { Text("Session title") },
253+ singleLine = true,
254+ )
255+ },
256+ confirmButton = {
257+ TextButton(onClick = onConfirm, enabled = title.isNotBlank()) { Text("Save") }
258+ },
259+ dismissButton = {
260+ TextButton(onClick = onDismiss) { Text("Cancel") }
261+ },
262+ )
218}
263}
219
264
220@Composable
265@Composable
@@ -222,6 +267,7 @@ private fun SessionCard(
222 session: Session,
267 session: Session,
223 preview: String?,
268 preview: String?,
224 onClick: () -> Unit,
269 onClick: () -> Unit,
270+ onRename: () -> Unit,
225 onDelete: () -> Unit,
271 onDelete: () -> Unit,
226) {
272) {
227 Card(
273 Card(
@@ -249,6 +295,9 @@ private fun SessionCard(
249 color = MaterialTheme.colorScheme.onSurfaceVariant,
295 color = MaterialTheme.colorScheme.onSurfaceVariant,
250 )
296 )
251 }
297 }
298+ IconButton(onClick = onRename) {
299+ Icon(Icons.Filled.Edit, contentDescription = "Rename")
300+ }
252 IconButton(onClick = onDelete) {
301 IconButton(onClick = onDelete) {
253 Icon(Icons.Filled.Delete, contentDescription = "Delete")
302 Icon(Icons.Filled.Delete, contentDescription = "Delete")
254 }
303 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+11
@@ -115,6 +115,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
115 }
115 }
116 }
116 }
117
117
118+ fun renameSession(session: Session, newTitle: String) {
119+ val conn = container.activeConnection.value ?: return
120+ val title = newTitle.trim()
121+ if (title.isEmpty() || title == session.title) return
122+ viewModelScope.launch {
123+ runCatching { conn.api.updateSession(session.id, title) }
124+ .onSuccess { refresh() }
125+ .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to rename session") }
126+ }
127+ }
128+
118 /** Quick-switch to a different saved server without leaving the session list. */
129 /** Quick-switch to a different saved server without leaving the session list. */
119 fun switchServer(profile: ServerProfile) {
130 fun switchServer(profile: ServerProfile) {
120 if (profile.id == activeProfileId) return
131 if (profile.id == activeProfileId) return
app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.ktnew file+203
@@ -0,0 +1,203 @@
1+package soy.iko.opencode.data.repo
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertFalse
5+import org.junit.Assert.assertTrue
6+import org.junit.Test
7+import soy.iko.opencode.data.model.AssistantMessage
8+import soy.iko.opencode.data.model.MessagePartRemoved
9+import soy.iko.opencode.data.model.MessagePartUpdated
10+import soy.iko.opencode.data.model.MessageRemoved
11+import soy.iko.opencode.data.model.MessageUpdated
12+import soy.iko.opencode.data.model.MessageWithParts
13+import soy.iko.opencode.data.model.SessionError
14+import soy.iko.opencode.data.model.SessionIdle
15+import soy.iko.opencode.data.model.TextPart
16+import soy.iko.opencode.data.model.UserMessage
17+
18+class MessageStoreTest {
19+
20+ private val session = "s1"
21+
22+ private fun msgUpdated(id: String, sid: String = session) =
23+ MessageUpdated(MessageUpdated.Props(AssistantMessage(id = id, sessionID = sid)))
24+
25+ private fun partUpdated(messageId: String, partId: String, text: String, sid: String = session) =
26+ MessagePartUpdated(
27+ MessagePartUpdated.Props(
28+ part = TextPart(id = partId, messageID = messageId, sessionID = sid, text = text),
29+ sessionID = sid,
30+ messageID = messageId,
31+ ),
32+ )
33+
34+ private fun partRemoved(messageId: String, partId: String, sid: String = session) =
35+ MessagePartRemoved(
36+ MessagePartRemoved.Props(sessionID = sid, messageID = messageId, partID = partId),
37+ )
38+
39+ private fun messageRemoved(messageId: String, sid: String = session) =
40+ MessageRemoved(MessageRemoved.Props(sessionID = sid, messageID = messageId))
41+
42+ // --- seed ---
43+
44+ @Test
45+ fun seedPopulatesSnapshot() {
46+ val store = MessageStore()
47+ store.seed(
48+ listOf(
49+ MessageWithParts(UserMessage("u1", session)),
50+ MessageWithParts(AssistantMessage("a1", session)),
51+ ),
52+ )
53+ assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
54+ }
55+
56+ // --- MessageUpdated ---
57+
58+ @Test
59+ fun messageUpdatedInsertsNewMessage() {
60+ val store = MessageStore()
61+ val changed = store.reduce(session, msgUpdated("m1"))
62+ assertTrue(changed)
63+ assertEquals("m1", store.snapshot().single().info.id)
64+ }
65+
66+ @Test
67+ fun messageUpdatedOverwritesExistingInfo() {
68+ val store = MessageStore()
69+ store.reduce(session, msgUpdated("m1"))
70+ val changed = store.reduce(session, msgUpdated("m1"))
71+ assertTrue(changed)
72+ assertEquals(1, store.snapshot().size)
73+ }
74+
75+ @Test
76+ fun messageUpdatedIgnoredForOtherSession() {
77+ val store = MessageStore()
78+ val changed = store.reduce(session, msgUpdated("m1", sid = "other"))
79+ assertFalse(changed)
80+ assertTrue(store.snapshot().isEmpty())
81+ }
82+
83+ // --- MessagePartUpdated ---
84+
85+ @Test
86+ fun partUpdatedAppendsToExistingMessage() {
87+ val store = MessageStore()
88+ store.reduce(session, msgUpdated("m1"))
89+ val changed = store.reduce(session, partUpdated("m1", "p1", "hello"))
90+ assertTrue(changed)
91+ val parts = store.snapshot().single().parts
92+ assertEquals(1, parts.size)
93+ assertEquals("hello", (parts[0] as TextPart).text)
94+ }
95+
96+ @Test
97+ fun partUpdatedCreatesSyntheticMessageWhenAbsent() {
98+ val store = MessageStore()
99+ store.reduce(session, partUpdated("m1", "p1", "hello"))
100+ val msg = store.snapshot().single()
101+ assertEquals("m1", msg.info.id)
102+ assertEquals("hello", (msg.parts.single() as TextPart).text)
103+ }
104+
105+ @Test
106+ fun partUpdatedReplacesPartWithSameId() {
107+ val store = MessageStore()
108+ store.reduce(session, partUpdated("m1", "p1", "hello"))
109+ store.reduce(session, partUpdated("m1", "p1", "hello world"))
110+ val parts = store.snapshot().single().parts
111+ assertEquals(1, parts.size)
112+ assertEquals("hello world", (parts[0] as TextPart).text)
113+ }
114+
115+ @Test
116+ fun partUpdatedPreservesInsertionOrderAcrossParts() {
117+ val store = MessageStore()
118+ store.reduce(session, partUpdated("m1", "p1", "one"))
119+ store.reduce(session, partUpdated("m1", "p2", "two"))
120+ store.reduce(session, partUpdated("m1", "p3", "three"))
121+ val parts = store.snapshot().single().parts
122+ assertEquals(listOf("p1", "p2", "p3"), parts.map { it.id })
123+ }
124+
125+ @Test
126+ fun partUpdatedIgnoredForOtherSession() {
127+ val store = MessageStore()
128+ val changed = store.reduce(session, partUpdated("m1", "p1", "x", sid = "other"))
129+ assertFalse(changed)
130+ assertTrue(store.snapshot().isEmpty())
131+ }
132+
133+ // --- MessagePartRemoved ---
134+
135+ @Test
136+ fun partRemovedDeletesMatchingPart() {
137+ val store = MessageStore()
138+ store.reduce(session, partUpdated("m1", "p1", "one"))
139+ store.reduce(session, partUpdated("m1", "p2", "two"))
140+ val changed = store.reduce(session, partRemoved("m1", "p1"))
141+ assertTrue(changed)
142+ val parts = store.snapshot().single().parts
143+ assertEquals(listOf("p2"), parts.map { it.id })
144+ }
145+
146+ @Test
147+ fun partRemovedReturnsFalseWhenPartNotFound() {
148+ val store = MessageStore()
149+ store.reduce(session, partUpdated("m1", "p1", "one"))
150+ val changed = store.reduce(session, partRemoved("m1", "p2"))
151+ assertFalse(changed)
152+ }
153+
154+ // --- MessageRemoved ---
155+
156+ @Test
157+ fun messageRemovedDeletesWholeMessage() {
158+ val store = MessageStore()
159+ store.reduce(session, msgUpdated("m1"))
160+ store.reduce(session, msgUpdated("m2"))
161+ val changed = store.reduce(session, messageRemoved("m1"))
162+ assertTrue(changed)
163+ assertEquals(listOf("m2"), store.snapshot().map { it.info.id })
164+ }
165+
166+ @Test
167+ fun messageRemovedReturnsFalseWhenMissing() {
168+ val store = MessageStore()
169+ val changed = store.reduce(session, messageRemoved("nope"))
170+ assertFalse(changed)
171+ }
172+
173+ // --- unrelated events ---
174+
175+ @Test
176+ fun unrelatedEventsDoNotMutateState() {
177+ val store = MessageStore()
178+ store.reduce(session, msgUpdated("m1"))
179+ val idle = store.reduce(session, SessionIdle(SessionIdle.Props(sessionID = session)))
180+ val error = store.reduce(session, SessionError(SessionError.Props(sessionID = session)))
181+ assertFalse(idle)
182+ assertFalse(error)
183+ assertEquals(listOf("m1"), store.snapshot().map { it.info.id })
184+ }
185+
186+ // --- end-to-end ordering ---
187+
188+ @Test
189+ fun fullReductionProducesOrderedMessagesWithParts() {
190+ val store = MessageStore()
191+ store.reduce(session, msgUpdated("u1"))
192+ store.reduce(session, msgUpdated("a1"))
193+ store.reduce(session, partUpdated("a1", "t1", "draft"))
194+ store.reduce(session, partUpdated("a1", "t1", "final"))
195+ store.reduce(session, partUpdated("a1", "t2", "second"))
196+
197+ val snapshot = store.snapshot()
198+ assertEquals(listOf("u1", "a1"), snapshot.map { it.info.id })
199+ val a1 = snapshot[1]
200+ assertEquals(listOf("t1", "t2"), a1.parts.map { it.id })
201+ assertEquals("final", (a1.parts[0] as TextPart).text)
202+ }
203+}