1@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
3package soy.iko.opencode.ui.chat
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.ExperimentalCoroutinesApi
7import kotlinx.coroutines.launch
8import kotlinx.coroutines.flow.first
9import kotlinx.coroutines.flow.toList
10import kotlinx.coroutines.test.StandardTestDispatcher
11import kotlinx.coroutines.test.TestScope
12import kotlinx.coroutines.test.resetMain
13import kotlinx.coroutines.test.runTest
14import kotlinx.coroutines.test.setMain
16import org.junit.Assert.assertEquals
17import org.junit.Assert.assertFalse
18import org.junit.Assert.assertNotNull
19import org.junit.Assert.assertNull
20import org.junit.Assert.assertTrue
21import org.junit.Before
23import soy.iko.opencode.data.model.Command
24import soy.iko.opencode.data.model.MessageWithParts
25import soy.iko.opencode.data.model.ModelInfo
26import soy.iko.opencode.data.model.Permission
27import soy.iko.opencode.data.model.PermissionResponse
28import soy.iko.opencode.data.model.PermissionUpdated
29import soy.iko.opencode.data.model.Provider
30import soy.iko.opencode.data.model.ProvidersResponse
31import soy.iko.opencode.data.model.ServerProfile
32import soy.iko.opencode.data.model.Session
33import soy.iko.opencode.data.model.SessionDeleted
34import soy.iko.opencode.data.model.SessionError
35import soy.iko.opencode.data.model.SessionIdle
36import soy.iko.opencode.data.model.SessionUpdated
37import soy.iko.opencode.data.model.UserMessage
38import soy.iko.opencode.data.network.EventStreamClient
39import soy.iko.opencode.ui.testing.FakeAppContainer
40import soy.iko.opencode.ui.testing.FakeEventStreamClient
41import soy.iko.opencode.ui.testing.FakeOpencodeApiClient
42import soy.iko.opencode.ui.testing.FakeOpencodeConnection
43import soy.iko.opencode.ui.testing.FakeSessionRepository
44import java.io.IOException
46class ChatViewModelTest {
47 private val testDispatcher = StandardTestDispatcher()
48 private val testScope = TestScope(testDispatcher)
52 Dispatchers.setMain(testDispatcher)
57 Dispatchers.resetMain()
60 private fun makeContainer(
61 api: FakeOpencodeApiClient = FakeOpencodeApiClient(),
62 events: FakeEventStreamClient = FakeEventStreamClient(),
63 repo: FakeSessionRepository = FakeSessionRepository(api, events),
64 profile: ServerProfile = ServerProfile(id = "s1", label = "Test", baseUrl = "http://localhost"),
66 val container = FakeAppContainer()
67 val conn = FakeOpencodeConnection(api, events, repo, profile)
68 container.setActiveConnection(conn)
72 /** Creates a ViewModel and lets its init coroutines start before returning. */
73 private fun makeVm(container: FakeAppContainer, sessionId: String = "session1"): ChatViewModel {
74 val vm = ChatViewModel(container, sessionId)
75 testScope.testScheduler.advanceUntilIdle()
82 fun send_queuesToOutboxWhenNotConnected() = testScope.runTest {
83 // The offline-outbox feature changed send()'s contract: when there's no active
84 // connection the prompt is accepted for offline queueing (returns true) rather than
85 // being rejected. (This test previously asserted the old "returns false" behavior.)
86 val container = FakeAppContainer()
87 val vm = makeVm(container)
88 assertTrue(vm.send("hello"))
92 fun send_returnsFalseForEmptyText() = testScope.runTest {
93 val container = makeContainer()
94 val vm = makeVm(container)
95 assertFalse(vm.send(""))
96 assertFalse(vm.send(" "))
100 fun send_returnsFalseWhenAlreadyRunning() = testScope.runTest {
101 val container = makeContainer()
102 val vm = makeVm(container)
103 assertTrue(vm.send("first"))
104 assertFalse(vm.send("second"))
108 fun send_clearsDraftImmediatelyAndSetsRunning() = testScope.runTest {
109 val container = makeContainer()
110 val vm = makeVm(container)
111 vm.updateDraft("hello world")
112 assertTrue(vm.send("hello world"))
113 assertEquals("", vm.draft.value)
114 assertTrue(vm.running.value)
118 fun send_successPersistsEmptyDraft() = testScope.runTest {
119 val container = makeContainer()
120 val vm = makeVm(container)
121 vm.updateDraft("hello")
122 assertTrue(vm.send("hello"))
123 testScope.testScheduler.advanceUntilIdle()
124 assertEquals("", container.fakeDraftStore.get("session1"))
125 assertNull(vm.failedDraft.value)
129 fun send_failureRestoresDraftAndClearsRunning() = testScope.runTest {
130 val api = FakeOpencodeApiClient()
131 val events = FakeEventStreamClient()
132 val repo = FakeSessionRepository(api, events)
133 repo.sendPromptThrows = IOException("network error")
134 val container = makeContainer(api = api, events = events, repo = repo)
135 val vm = makeVm(container)
136 vm.updateDraft("hello")
137 assertTrue(vm.send("hello"))
138 testScope.testScheduler.advanceUntilIdle()
139 assertEquals("hello", vm.draft.value)
140 assertEquals("hello", vm.failedDraft.value)
141 assertFalse(vm.running.value)
142 val errors = mutableListOf<ChatError>()
143 val collectJob = launch { vm.errorEvents.toList(errors) }
144 assertTrue(vm.send("hello"))
145 testScheduler.advanceUntilIdle()
147 assertTrue(errors.isNotEmpty())
151 fun send_failureDoesNotRestoreDraftIfUserTypedNewText() = testScope.runTest {
152 val api = FakeOpencodeApiClient()
153 val events = FakeEventStreamClient()
154 val repo = FakeSessionRepository(api, events)
155 repo.sendPromptThrows = IOException("network error")
156 val container = makeContainer(api = api, events = events, repo = repo)
157 val vm = makeVm(container)
158 vm.updateDraft("original")
159 assertTrue(vm.send("original"))
160 vm.updateDraft("new text")
161 testScope.testScheduler.advanceUntilIdle()
162 assertEquals("new text", vm.draft.value)
165 // --- retryFailed() ---
168 fun retryFailed_noopWhenNoFailedDraft() = testScope.runTest {
169 val container = makeContainer()
170 val vm = makeVm(container)
172 testScope.testScheduler.advanceUntilIdle()
173 assertFalse(vm.running.value)
177 fun retryFailed_resendsFailedDraft() = testScope.runTest {
178 val api = FakeOpencodeApiClient()
179 val events = FakeEventStreamClient()
180 val repo = FakeSessionRepository(api, events)
181 repo.sendPromptThrows = IOException("network error")
182 val container = makeContainer(api = api, events = events, repo = repo)
183 val vm = makeVm(container)
184 vm.updateDraft("hello")
185 assertTrue(vm.send("hello"))
186 testScope.testScheduler.advanceUntilIdle()
187 assertEquals("hello", vm.failedDraft.value)
189 repo.sendPromptThrows = null
191 testScope.testScheduler.advanceUntilIdle()
192 assertNull(vm.failedDraft.value)
195 // --- runCommand() ---
198 fun runCommand_setsRunning() = testScope.runTest {
199 val container = makeContainer()
200 val vm = makeVm(container)
201 vm.runCommand(Command(name = "compact"))
202 assertTrue(vm.running.value)
206 fun runCommand_rejectedWhenAlreadyRunning() = testScope.runTest {
207 val container = makeContainer()
208 val vm = makeVm(container)
209 assertTrue(vm.send("hello"))
210 assertFalse(vm.running.value.not()) // running is true
211 // Collect error events so we can assert the rejection actually fired. A plain
212 // assertTrue(vm.running.value) afterwards can't distinguish rejection from
213 // acceptance (both leave running true), so without this the test would pass
214 // even if the beginRun() guard were removed.
215 val errors = mutableListOf<ChatError>()
216 val collectJob = launch { vm.errorEvents.toList(errors) }
217 vm.runCommand(Command(name = "compact"))
218 testScheduler.advanceUntilIdle()
220 // Still running from the first send; runCommand should not override.
221 assertTrue(vm.running.value)
222 // The rejection emits a command_busy error — the distinguishing signal that the
223 // guard fired. Without it, runCommand would have proceeded and no error would fire.
224 assertTrue("expected a command_busy error on rejection", errors.isNotEmpty())
230 fun abort_clearsRunning() = testScope.runTest {
231 val container = makeContainer()
232 val vm = makeVm(container)
233 assertTrue(vm.send("hello"))
234 testScope.testScheduler.advanceUntilIdle()
236 testScope.testScheduler.advanceUntilIdle()
237 assertFalse(vm.running.value)
241 fun abort_emitsStopUndoWithLastPrompt() = testScope.runTest {
242 val container = makeContainer()
243 val vm = makeVm(container)
244 assertTrue(vm.send("hello there"))
245 testScope.testScheduler.advanceUntilIdle()
247 testScope.testScheduler.advanceUntilIdle()
248 // The undo event carries the last-sent prompt so the UI can show "Stopped — Undo".
249 val prompt = vm.stopUndoEvents.first()
250 assertEquals("hello there", prompt)
254 fun resendLastPrompt_reSendsTheTrackedPrompt() = testScope.runTest {
255 val container = makeContainer()
256 val vm = makeVm(container)
257 assertTrue(vm.send("original prompt"))
258 testScope.testScheduler.advanceUntilIdle()
259 // Abort surfaces the undo event; resendLastPrompt re-sends the tracked text.
261 testScope.testScheduler.advanceUntilIdle()
262 vm.resendLastPrompt()
263 testScope.testScheduler.advanceUntilIdle()
264 // The send should have re-queued the prompt (running re-lights).
265 assertTrue(vm.running.value)
268 // --- respondPermission() ---
271 fun respondPermission_clearsPendingOptimistically() = testScope.runTest {
272 val events = FakeEventStreamClient()
273 val container = makeContainer(events = events)
274 val vm = makeVm(container)
275 val perm = Permission(id = "p1", sessionID = "session1")
277 events.fakeEvents.tryEmit(PermissionUpdated(perm))
278 testScope.testScheduler.advanceUntilIdle()
279 assertEquals("p1", vm.pendingPermission.value?.id)
281 vm.respondPermission(perm, PermissionResponse.ONCE)
282 assertNull(vm.pendingPermission.value)
286 fun respondPermission_restoresPendingOnFailure() = testScope.runTest {
287 val api = FakeOpencodeApiClient()
288 api.respondPermissionThrows = IOException("network error")
289 val container = makeContainer(api = api)
290 val vm = makeVm(container)
291 val perm = Permission(id = "p1", sessionID = "session1")
293 val events = (container.activeConnection.value!!.events as FakeEventStreamClient)
294 events.fakeEvents.tryEmit(PermissionUpdated(perm))
295 testScope.testScheduler.advanceUntilIdle()
296 assertEquals("p1", vm.pendingPermission.value?.id)
298 val errors = mutableListOf<ChatError>()
299 val collectJob = launch { vm.errorEvents.toList(errors) }
300 vm.respondPermission(perm, PermissionResponse.ONCE)
301 assertNull(vm.pendingPermission.value)
302 testScope.testScheduler.advanceUntilIdle()
304 assertEquals("p1", vm.pendingPermission.value?.id)
305 assertTrue(errors.isNotEmpty())
308 // --- SSE event handling ---
311 fun sseSessionIdle_resetsRunning() = testScope.runTest {
312 val events = FakeEventStreamClient()
313 val container = makeContainer(events = events)
314 val vm = makeVm(container)
315 assertTrue(vm.send("hello"))
316 testScope.testScheduler.advanceUntilIdle()
317 events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "session1")))
318 testScope.testScheduler.advanceUntilIdle()
319 assertFalse(vm.running.value)
323 fun sseSessionError_resetsRunningAndEmitsError() = testScope.runTest {
324 val events = FakeEventStreamClient()
325 val container = makeContainer(events = events)
326 val vm = makeVm(container)
327 val errors = mutableListOf<ChatError>()
328 val collectJob = launch { vm.errorEvents.toList(errors) }
329 assertTrue(vm.send("hello"))
330 testScope.testScheduler.advanceUntilIdle()
331 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
332 testScope.testScheduler.advanceUntilIdle()
334 assertFalse(vm.running.value)
335 assertTrue(errors.isNotEmpty())
339 fun sseSessionIdle_ignoresOtherSession() = testScope.runTest {
340 val events = FakeEventStreamClient()
341 val container = makeContainer(events = events)
342 val vm = makeVm(container)
343 assertTrue(vm.send("hello"))
344 testScope.testScheduler.advanceUntilIdle()
345 events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "other")))
346 testScope.testScheduler.advanceUntilIdle()
347 assertTrue(vm.running.value)
351 fun sseSessionUpdated_updatesTitle() = testScope.runTest {
352 val events = FakeEventStreamClient()
353 val container = makeContainer(events = events)
354 val vm = makeVm(container)
355 events.fakeEvents.tryEmit(
356 SessionUpdated(SessionUpdated.Props(info = Session(id = "session1", title = "My Chat"))),
358 testScope.testScheduler.advanceUntilIdle()
359 assertEquals("My Chat", vm.sessionTitle.value)
363 fun sseSessionDeleted_setsDeletedFlag() = testScope.runTest {
364 val events = FakeEventStreamClient()
365 val container = makeContainer(events = events)
366 val vm = makeVm(container)
367 events.fakeEvents.tryEmit(
368 SessionDeleted(SessionDeleted.Props(sessionID = "session1")),
370 testScope.testScheduler.advanceUntilIdle()
371 assertTrue(vm.sessionDeleted.value)
374 // --- Connection state ---
377 fun sseDisconnected_keepsRunningAndFlagsInterrupted() = testScope.runTest {
378 val events = FakeEventStreamClient()
379 val container = makeContainer(events = events)
380 val vm = makeVm(container)
381 assertTrue(vm.send("hello"))
382 testScope.testScheduler.advanceUntilIdle()
383 events.fakeState.value = EventStreamClient.ConnectionState.Disconnected
384 testScope.testScheduler.advanceUntilIdle()
385 // A transient SSE drop does NOT clear _running — the run is still active on the
386 // server, and showing it as finished would mislead the user. Instead the stream-
387 // interrupted flag is set so the working row shows a "reconnecting" visual.
388 assertTrue(vm.running.value)
389 assertTrue(vm.streamInterrupted.value)
390 // Reconnecting clears the interrupted flag but keeps running.
391 events.fakeState.value = EventStreamClient.ConnectionState.Connected
392 testScope.testScheduler.advanceUntilIdle()
393 assertTrue(vm.running.value)
394 assertFalse(vm.streamInterrupted.value)
398 fun sseFailed_resetsRunning() = testScope.runTest {
399 val events = FakeEventStreamClient()
400 val container = makeContainer(events = events)
401 val vm = makeVm(container)
402 assertTrue(vm.send("hello"))
403 testScope.testScheduler.advanceUntilIdle()
404 events.fakeState.value = EventStreamClient.ConnectionState.Failed
405 testScope.testScheduler.advanceUntilIdle()
406 assertFalse(vm.running.value)
409 // --- Draft persistence ---
412 fun updateDraft_doesNotPersistImmediately() = testScope.runTest {
413 val container = makeContainer()
414 val vm = makeVm(container)
415 vm.updateDraft("typing")
416 assertEquals("", container.fakeDraftStore.get("session1"))
417 assertEquals("typing", vm.draft.value)
421 fun updateDraft_persistsAfterDebounce() = testScope.runTest {
422 val container = makeContainer()
423 val vm = makeVm(container)
424 vm.updateDraft("typing")
425 testScope.testScheduler.advanceUntilIdle()
426 assertEquals("typing", container.fakeDraftStore.get("session1"))
430 fun onCleared_flushesPendingDraft() = testScope.runTest {
431 val container = makeContainer()
432 val vm = makeVm(container)
433 vm.updateDraft("unsent draft")
434 testScope.testScheduler.advanceUntilIdle()
435 // onCleared() is protected — invoke via reflection
436 val method = androidx.lifecycle.ViewModel::class.java.getDeclaredMethod("onCleared")
437 method.isAccessible = true
439 assertEquals("unsent draft", container.fakeDraftStore.lastFlushed?.second)
442 // --- Catalog loading ---
445 fun catalogs_loadFromApi() = testScope.runTest {
446 val api = FakeOpencodeApiClient()
447 api.agentsList = listOf(soy.iko.opencode.data.model.Agent(name = "code"))
448 api.commandsList = listOf(Command(name = "compact"))
449 val container = makeContainer(api = api)
450 val vm = makeVm(container)
451 testScope.testScheduler.advanceUntilIdle()
452 assertEquals(1, vm.agents.value.size)
453 assertEquals("code", vm.agents.value[0].name)
454 assertEquals(1, vm.commands.value.size)
458 fun reloadModels_invalidatesCacheAndRefetches() = testScope.runTest {
459 val api = FakeOpencodeApiClient()
460 val container = makeContainer(api = api)
461 val vm = makeVm(container)
462 testScope.testScheduler.advanceUntilIdle()
463 assertFalse(api.providersCacheInvalidated)
465 api.providersResponse = ProvidersResponse(
467 Provider(id = "p1", name = "Provider 1", models = mapOf("m1" to ModelInfo(id = "m1", name = "Model 1"))),
471 testScope.testScheduler.advanceUntilIdle()
472 assertTrue(api.providersCacheInvalidated)
473 assertTrue(vm.models.value.isNotEmpty())
476 // --- reconnect() ---
479 fun reconnect_connectsToMostRecentProfile() = testScope.runTest {
480 val container = FakeAppContainer()
481 container.fakeProfileStore.setProfiles(
482 listOf(ServerProfile(id = "p1", label = "Server 1", baseUrl = "http://localhost")),
484 val vm = makeVm(container)
486 testScope.testScheduler.advanceUntilIdle()
487 assertEquals(1, container.connectCalls.size)
488 assertEquals("p1", container.connectCalls[0].id)
492 fun reconnect_withoutProfilesDoesNothing() = testScope.runTest {
493 val container = FakeAppContainer()
494 val vm = makeVm(container)
496 testScope.testScheduler.advanceUntilIdle()
497 assertEquals(0, container.connectCalls.size)
501 fun reconnect_skipsWhenAlreadyConnected() = testScope.runTest {
502 val container = makeContainer()
503 val vm = makeVm(container)
505 testScope.testScheduler.advanceUntilIdle()
506 assertEquals(0, container.connectCalls.size)
509 // --- Connection reset on server switch ---
512 fun connectionChange_resetsRunningAndErrorAndPermission() = testScope.runTest {
513 val events = FakeEventStreamClient()
514 val container = makeContainer(events = events)
515 val vm = makeVm(container)
516 assertTrue(vm.send("hello"))
517 testScope.testScheduler.advanceUntilIdle()
518 // Switch to a new connection
519 val api2 = FakeOpencodeApiClient()
520 val events2 = FakeEventStreamClient()
521 val repo2 = FakeSessionRepository(api2, events2)
522 val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
523 container.setActiveConnection(conn2)
524 testScope.testScheduler.advanceUntilIdle()
525 assertFalse(vm.running.value)
526 assertNull(vm.pendingPermission.value)
529 // --- rapid successive errors ---
532 fun rapidSuccessiveErrors_bothDeliveredAsEvents() = testScope.runTest {
533 val api = FakeOpencodeApiClient()
534 val events = FakeEventStreamClient()
535 val repo = FakeSessionRepository(api, events)
536 repo.sendPromptThrows = IOException("err")
537 val container = makeContainer(api = api, events = events, repo = repo)
538 val vm = makeVm(container)
540 val errors = mutableListOf<ChatError>()
541 val collectJob = launch { vm.errorEvents.toList(errors) }
543 // Two rapid sends that both fail — both should produce independent error
544 // events, not just the last one (the bug with StateFlow-keyed effects).
545 assertTrue(vm.send("first"))
546 testScheduler.advanceUntilIdle()
547 assertTrue(vm.send("second"))
548 testScheduler.advanceUntilIdle()
551 assertEquals(2, errors.size)
555 fun messagesFlowRetry_emitsErrorOnFailure() = testScope.runTest {
556 val api = FakeOpencodeApiClient()
557 val events = FakeEventStreamClient()
558 // Use a controllable flow so we can simulate a re-emission after an error.
559 val messagesFlow = kotlinx.coroutines.flow.MutableStateFlow(
560 listOf(MessageWithParts(info = UserMessage(id = "m1", sessionID = "session1")))
562 val repo = FakeSessionRepository(api, events)
563 repo.observeMessagesOverride = messagesFlow
564 val container = makeContainer(api = api, events = events, repo = repo)
565 val vm = makeVm(container)
566 val errors = mutableListOf<ChatError>()
567 val collectJob = launch { vm.errorEvents.toList(errors) }
568 // Subscribe to messages so the flow's retryWhen is active.
569 val collector = launch { vm.messages.collect { /* keep subscribed */ } }
570 testScope.testScheduler.advanceUntilIdle()
571 // Set an error via SSE
572 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
573 testScope.testScheduler.advanceUntilIdle()
576 assertTrue(errors.isNotEmpty())
579 // --- Connection switch clears session title ---
582 fun connectionChange_clearsSessionTitle() = testScope.runTest {
583 val events = FakeEventStreamClient()
584 val container = makeContainer(events = events)
585 val vm = makeVm(container)
586 // Set the title via SSE event
587 events.fakeEvents.tryEmit(
588 SessionUpdated(SessionUpdated.Props(info = Session(id = "session1", title = "Old Title"))),
590 testScope.testScheduler.advanceUntilIdle()
591 assertEquals("Old Title", vm.sessionTitle.value)
592 // Switch to a new connection
593 val api2 = FakeOpencodeApiClient()
594 val events2 = FakeEventStreamClient()
595 val repo2 = FakeSessionRepository(api2, events2)
596 val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
597 container.setActiveConnection(conn2)
598 testScope.testScheduler.advanceUntilIdle()
599 assertNull(vm.sessionTitle.value)
602 // --- editMessage ---
605 fun editMessage_prefillsDraftWithText() = testScope.runTest {
606 val container = makeContainer()
607 val vm = makeVm(container)
608 vm.editMessage("m1", "the original prompt")
609 testScheduler.advanceUntilIdle()
610 assertEquals("the original prompt", vm.draft.value)
613 // --- attachment persistence ---
615 private fun makeAttachment(id: String) = PendingAttachment(
620 part = soy.iko.opencode.data.model.FilePromptPart(
622 url = "data:text/plain;base64,QUJD",
623 filename = "$id.txt",
628 fun addAttachment_persistsToStore() = testScope.runTest {
629 val container = makeContainer()
630 val vm = makeVm(container)
631 vm.addAttachment(makeAttachment("a1"))
632 testScheduler.advanceUntilIdle()
633 assertEquals(1, container.fakeAttachmentDraftStore.load("session1").size)
637 fun attachments_restoredFromStoreOnInit() = testScope.runTest {
638 val container = makeContainer()
639 // Pre-populate the store as if a prior compose was interrupted by process death.
640 val att = makeAttachment("a1")
641 container.fakeAttachmentDraftStore.save(
643 listOf(soy.iko.opencode.data.repo.PersistedAttachment(att.id, att.name, att.mime, att.part.url, att.part.filename)),
645 val vm = makeVm(container)
646 testScheduler.advanceUntilIdle()
647 assertEquals(1, vm.attachments.value.size)
648 assertEquals("a1", vm.attachments.value[0].id)
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