1package soy.iko.opencode.data.repo
3import org.junit.Assert.assertEquals
4import org.junit.Assert.assertFalse
5import org.junit.Assert.assertTrue
7import soy.iko.opencode.data.model.AssistantMessage
8import soy.iko.opencode.data.model.MessagePartRemoved
9import soy.iko.opencode.data.model.MessagePartUpdated
10import soy.iko.opencode.data.model.MessageRemoved
11import soy.iko.opencode.data.model.MessageUpdated
12import soy.iko.opencode.data.model.MessageWithParts
13import soy.iko.opencode.data.model.SessionError
14import soy.iko.opencode.data.model.SessionIdle
15import soy.iko.opencode.data.model.TextPart
16import soy.iko.opencode.data.model.UnknownMessage
17import soy.iko.opencode.data.model.UserMessage
19class MessageStoreTest {
21 private val session = "s1"
23 private fun msgUpdated(id: String, sid: String = session) =
24 MessageUpdated(MessageUpdated.Props(AssistantMessage(id = id, sessionID = sid)))
26 private fun partUpdated(messageId: String, partId: String, text: String, sid: String = session) =
28 MessagePartUpdated.Props(
29 part = TextPart(id = partId, messageID = messageId, sessionID = sid, text = text),
31 messageID = messageId,
35 private fun partRemoved(messageId: String, partId: String, sid: String = session) =
37 MessagePartRemoved.Props(sessionID = sid, messageID = messageId, partID = partId),
40 private fun messageRemoved(messageId: String, sid: String = session) =
41 MessageRemoved(MessageRemoved.Props(sessionID = sid, messageID = messageId))
46 fun seedPopulatesSnapshot() {
47 val store = MessageStore()
50 MessageWithParts(UserMessage("u1", session)),
51 MessageWithParts(AssistantMessage("a1", session)),
54 assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
57 // --- seed merge (race between streamed part and initial load) ---
60 fun seedMergesStreamedPartsWithSnapshotInsteadOfOverwriting() {
61 val store = MessageStore()
62 // A part streams in before the initial load completes, creating a synthetic
63 // placeholder message.
64 store.reduce(session, partUpdated("a1", "t1", "streamed"))
65 // The REST snapshot for the same message arrives.
69 AssistantMessage(id = "a1", sessionID = session),
70 parts = listOf(TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snapshot")),
74 val msg = store.snapshot().single()
75 assertTrue("info should be the real one", msg.info is AssistantMessage)
76 // The streamed part (newer) must win over the snapshot's duplicate.
77 assertEquals("streamed", (msg.parts.single() as TextPart).text)
81 fun seedAppendsSnapshotOnlyPartsAfterStreamedOnes() {
82 val store = MessageStore()
83 store.reduce(session, partUpdated("a1", "t2", "streamed-t2"))
87 AssistantMessage(id = "a1", sessionID = session),
89 TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snap-t1"),
90 TextPart(id = "t2", messageID = "a1", sessionID = session, text = "snap-t2"),
95 val msg = store.snapshot().single()
97 assertEquals(listOf("t1", "t2"), parts.map { it.id })
98 assertEquals("snap-t1", (parts[0] as TextPart).text)
99 assertEquals("streamed-t2", (parts[1] as TextPart).text)
103 fun seedKeepsEventResolvedInfoOverSyntheticSnapshot() {
104 val store = MessageStore()
105 // A MessageUpdated event resolved the real info, then a part streamed in.
106 store.reduce(session, msgUpdated("a1"))
107 store.reduce(session, partUpdated("a1", "t1", "streamed"))
108 // A stale snapshot arrives whose info is synthetic; it must not downgrade.
112 UnknownMessage(id = "a1", sessionID = session),
113 parts = listOf(TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snapshot")),
117 val msg = store.snapshot().single()
118 assertTrue("event-resolved info must be kept", msg.info is AssistantMessage)
119 assertEquals("streamed", (msg.parts.first() as TextPart).text)
122 // --- MessageUpdated ---
125 fun messageUpdatedInsertsNewMessage() {
126 val store = MessageStore()
127 val changed = store.reduce(session, msgUpdated("m1"))
129 assertEquals("m1", store.snapshot().single().info.id)
133 fun messageUpdatedOverwritesExistingInfoWhenChanged() {
134 val store = MessageStore()
135 store.reduce(session, msgUpdated("m1"))
136 // A second update carrying genuinely new info (e.g. cost/token totals filled in on
137 // completion) overwrites in place and reports a change.
138 val changed = store.reduce(
140 MessageUpdated(MessageUpdated.Props(AssistantMessage(id = "m1", sessionID = session, cost = 0.42))),
143 assertEquals(1, store.snapshot().size)
144 assertEquals(0.42, (store.snapshot().single().info as AssistantMessage).cost!!, 1e-9)
148 fun messageUpdatedWithIdenticalInfoIsNoOp() {
149 val store = MessageStore()
150 store.reduce(session, msgUpdated("m1"))
151 // Re-sending byte-identical info publishes nothing, avoiding a redundant O(N) snapshot
152 // (mirrors upsertPart's identical-part guard).
153 val changed = store.reduce(session, msgUpdated("m1"))
155 assertEquals(1, store.snapshot().size)
159 fun messageUpdatedIgnoredForOtherSession() {
160 val store = MessageStore()
161 val changed = store.reduce(session, msgUpdated("m1", sid = "other"))
163 assertTrue(store.snapshot().isEmpty())
166 // --- MessagePartUpdated ---
169 fun partUpdatedAppendsToExistingMessage() {
170 val store = MessageStore()
171 store.reduce(session, msgUpdated("m1"))
172 val changed = store.reduce(session, partUpdated("m1", "p1", "hello"))
174 val parts = store.snapshot().single().parts
175 assertEquals(1, parts.size)
176 assertEquals("hello", (parts[0] as TextPart).text)
180 fun partUpdatedCreatesSyntheticMessageWhenAbsent() {
181 val store = MessageStore()
182 store.reduce(session, partUpdated("m1", "p1", "hello"))
183 val msg = store.snapshot().single()
184 assertEquals("m1", msg.info.id)
185 assertEquals("hello", (msg.parts.single() as TextPart).text)
189 fun partUpdatedReplacesPartWithSameId() {
190 val store = MessageStore()
191 store.reduce(session, partUpdated("m1", "p1", "hello"))
192 store.reduce(session, partUpdated("m1", "p1", "hello world"))
193 val parts = store.snapshot().single().parts
194 assertEquals(1, parts.size)
195 assertEquals("hello world", (parts[0] as TextPart).text)
199 fun partUpdatedPreservesInsertionOrderAcrossParts() {
200 val store = MessageStore()
201 store.reduce(session, partUpdated("m1", "p1", "one"))
202 store.reduce(session, partUpdated("m1", "p2", "two"))
203 store.reduce(session, partUpdated("m1", "p3", "three"))
204 val parts = store.snapshot().single().parts
205 assertEquals(listOf("p1", "p2", "p3"), parts.map { it.id })
209 fun partUpdatedIgnoredForOtherSession() {
210 val store = MessageStore()
211 val changed = store.reduce(session, partUpdated("m1", "p1", "x", sid = "other"))
213 assertTrue(store.snapshot().isEmpty())
216 // --- MessagePartRemoved ---
219 fun partRemovedDeletesMatchingPart() {
220 val store = MessageStore()
221 store.reduce(session, partUpdated("m1", "p1", "one"))
222 store.reduce(session, partUpdated("m1", "p2", "two"))
223 val changed = store.reduce(session, partRemoved("m1", "p1"))
225 val parts = store.snapshot().single().parts
226 assertEquals(listOf("p2"), parts.map { it.id })
230 fun partRemovedReturnsFalseWhenPartNotFound() {
231 val store = MessageStore()
232 store.reduce(session, partUpdated("m1", "p1", "one"))
233 val changed = store.reduce(session, partRemoved("m1", "p2"))
237 // --- MessageRemoved ---
240 fun messageRemovedDeletesWholeMessage() {
241 val store = MessageStore()
242 store.reduce(session, msgUpdated("m1"))
243 store.reduce(session, msgUpdated("m2"))
244 val changed = store.reduce(session, messageRemoved("m1"))
246 assertEquals(listOf("m2"), store.snapshot().map { it.info.id })
250 fun messageRemovedReturnsFalseWhenMissing() {
251 val store = MessageStore()
252 val changed = store.reduce(session, messageRemoved("nope"))
256 // --- unrelated events ---
259 fun unrelatedEventsDoNotMutateState() {
260 val store = MessageStore()
261 store.reduce(session, msgUpdated("m1"))
262 val idle = store.reduce(session, SessionIdle(SessionIdle.Props(sessionID = session)))
263 val error = store.reduce(session, SessionError(SessionError.Props(sessionID = session)))
266 assertEquals(listOf("m1"), store.snapshot().map { it.info.id })
269 // --- seed return value (skip unnecessary snapshot on reconnect) ---
272 fun seedReturnsTrueWhenAddingNewMessages() {
273 val store = MessageStore()
274 val changed = store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
279 fun seedReturnsFalseWhenReSeedingIdenticalMessages() {
280 val store = MessageStore()
281 val messages = listOf(MessageWithParts(UserMessage("u1", session)))
283 val changed = store.seed(messages)
288 fun seedReturnsTrueWhenMessagePartsDiffer() {
289 val store = MessageStore()
290 store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
291 val changed = store.seed(
292 listOf(MessageWithParts(UserMessage("u1", session), listOf(TextPart(id = "t1")))),
298 fun seedPruneReturnsTrueWhenMessagesRemoved() {
299 val store = MessageStore()
301 MessageWithParts(UserMessage("u1", session)),
302 MessageWithParts(AssistantMessage("a1", session)),
304 val changed = store.seed(listOf(MessageWithParts(UserMessage("u1", session))), prune = true)
309 fun seedPruneReturnsFalseWhenNoMessagesRemoved() {
310 val store = MessageStore()
311 val messages = listOf(MessageWithParts(UserMessage("u1", session)))
313 val changed = store.seed(messages, prune = true)
317 // --- seed prune (re-seed after SSE reconnect) ---
320 fun seedPruneRemovesMessagesNotInSnapshot() {
321 val store = MessageStore()
323 MessageWithParts(UserMessage("u1", session)),
324 MessageWithParts(AssistantMessage("a1", session)),
326 // Re-seed with only u1 — a1 was deleted on the server during the gap.
328 listOf(MessageWithParts(UserMessage("u1", session))),
331 assertEquals(listOf("u1"), store.snapshot().map { it.info.id })
335 fun seedWithoutPruneKeepsAllMessages() {
336 val store = MessageStore()
338 MessageWithParts(UserMessage("u1", session)),
339 MessageWithParts(AssistantMessage("a1", session)),
341 // Without prune, messages absent from the snapshot are retained.
342 store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
343 assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
346 // --- end-to-end ordering ---
349 fun fullReductionProducesOrderedMessagesWithParts() {
350 val store = MessageStore()
351 store.reduce(session, msgUpdated("u1"))
352 store.reduce(session, msgUpdated("a1"))
353 store.reduce(session, partUpdated("a1", "t1", "draft"))
354 store.reduce(session, partUpdated("a1", "t1", "final"))
355 store.reduce(session, partUpdated("a1", "t2", "second"))
357 val snapshot = store.snapshot()
358 assertEquals(listOf("u1", "a1"), snapshot.map { it.info.id })
360 assertEquals(listOf("t1", "t2"), a1.parts.map { it.id })
361 assertEquals("final", (a1.parts[0] as TextPart).text)
367 fun evictionCapsMessageCountKeepingNewest() {
368 val store = MessageStore()
369 // Seed exactly at the cap.
370 val messages = (1..store.maxMessages).map { i ->
371 MessageWithParts(UserMessage("m$i", session))
374 assertEquals(store.maxMessages, store.snapshot().size)
376 // Adding one more via reduce should evict the oldest.
377 store.reduce(session, msgUpdated("m${store.maxMessages + 1}"))
378 assertEquals(store.maxMessages, store.snapshot().size)
379 // The oldest (m1) should be gone; the newest should be present.
380 val ids = store.snapshot().map { it.info.id }
381 assertFalse("m1 should have been evicted", ids.contains("m1"))
382 assertTrue("m${store.maxMessages} should still be present", ids.contains("m${store.maxMessages}"))
383 assertTrue("newest message should be present", ids.contains("m${store.maxMessages + 1}"))
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list