git.iko.soy/opencode-android
on master help sign in
Fix SSE timeout killing the event stream every 60s, restore permission dialog on failure, defer draft persistence until send succeeds, make FileViewModel connection-aware, switch to collectLatest to avoid stale SSE subscriptions, buffer SSE events against slow subscribers, add configChanges for rotation, centralize network constants, enable Gradle parallel/config-cache, add testOptions, and expand unit test coverage for retry backoff timing and session repository seeding
commit0117f4ca25c431b6022d10571ae74ea8f1d1b4bc authoriko <mail@iko.soy> dateMon 29 Jun 2026, 18:09 (3 months ago) parent44de46e
14 files changed+314−56
app/build.gradle.kts+10 app/src/main/AndroidManifest.xml+1 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+17−19 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+11−4 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+52 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+4−4 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+27−15 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+2−1 app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+15−6 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+4−7 app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.kt+37 app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+59 app/src/test/java/soy/iko/opencode/data/repo/SessionRepositoryTest.kt+73 gradle.properties+2
app/build.gradle.kts+10
@@ -89,6 +89,16 @@ android {
8989 excludes += "/META-INF/{AL2.0,LGPL2.1}"
9090 }
9191 }
92+
93+ testOptions {
94+ unitTests {
95+ // Return default values for Android framework stubs (e.g. Log.d, Context.getString)
96+ // instead of throwing "not mocked" exceptions, so JVM unit tests that brush
97+ // against Android APIs don't need Robolectric.
98+ isReturnDefaultValues = true
99+ isIncludeAndroidResources = true
100+ }
101+ }
92102}
93103
94104dependencies {
app/src/main/AndroidManifest.xml+1
@@ -22,6 +22,7 @@
2222 android:name=".MainActivity"
2323 android:exported="true"
2424 android:launchMode="singleTop"
25+ android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode"
2526 android:theme="@style/Theme.OpencodeAndroid.Splash">
2627 <intent-filter>
2728 <action android:name="android.intent.action.MAIN" />
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+17−19
@@ -7,6 +7,7 @@ import io.ktor.client.plugins.sse.sse
77import io.ktor.sse.ServerSentEvent
88import kotlinx.coroutines.CancellationException
99import kotlinx.coroutines.CoroutineScope
10+import kotlinx.coroutines.channels.BufferOverflow
1011import kotlinx.coroutines.channels.Channel
1112import kotlinx.coroutines.channels.ChannelResult
1213import kotlinx.coroutines.flow.Flow
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.SharedFlow
1516import kotlinx.coroutines.flow.StateFlow
1617import kotlinx.coroutines.flow.SharingStarted
1718import kotlinx.coroutines.flow.asStateFlow
19+import kotlinx.coroutines.flow.buffer
1820import kotlinx.coroutines.flow.channelFlow
1921import kotlinx.coroutines.flow.produceIn
2022import kotlinx.coroutines.flow.shareIn
@@ -42,6 +44,10 @@ class EventStreamClient(
4244 val state: StateFlow<ConnectionState> = _state.asStateFlow()
4345
4446 val events: SharedFlow<BusEvent> = stream()
47+ // Buffer so a slow subscriber (e.g. a reducer under lock) can't suspend send()
48+ // and stall the SSE read loop. Drop oldest on overflow — the next event carries
49+ // the freshest state, so a dropped interim delta is harmless.
50+ .buffer(NetworkConfig.sseEventBufferCapacity, BufferOverflow.DROP_OLDEST)
4551 .shareIn(
4652 scope = scope,
4753 started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
@@ -56,26 +62,26 @@ class EventStreamClient(
5662
5763 private fun stream(): Flow<BusEvent> = channelFlow {
5864 val scope = this
59− var backoffMs = INITIAL_BACKOFF_MS
65+ var backoffMs = NetworkConfig.sseInitialBackoffMs
6066 while (isActive) {
6167 _state.value = ConnectionState.Connecting
6268 try {
6369 client.sse("event") {
64− backoffMs = INITIAL_BACKOFF_MS
70+ backoffMs = NetworkConfig.sseInitialBackoffMs
6571 // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
6672 // element can be raced against an idle timeout via select.
6773 val events = incoming.produceIn(scope)
6874 try {
6975 while (isActive) {
70− // The OkHttp engine is configured with readTimeout=0 (the SSE
71− // stream must never time out on read), so a socket the peer never
72− // closed would otherwise hang until the OS TCP keepalive kicks in
73− // — minutes to hours on stock Linux. If nothing arrives within
74− // IDLE_TIMEOUT_MS, treat the connection as half-open and drop it
75− // so the outer loop reconnects.
76+ // The OkHttp engine has readTimeout=0 (the SSE stream must
77+ // never time out on read), so a socket the peer never closed
78+ // would otherwise hang until OS TCP keepalive kicks in —
79+ // minutes to hours on stock Linux. If nothing arrives within
80+ // the idle timeout, treat the connection as half-open and
81+ // drop it so the outer loop reconnects.
7682 val result: ChannelResult<ServerSentEvent>? = select {
7783 events.onReceiveCatching { it }
78− onTimeout(IDLE_TIMEOUT_MS) { null }
84+ onTimeout(NetworkConfig.sseIdleTimeoutMs) { null }
7985 }
8086 // Timeout: drop and reconnect.
8187 if (result == null) throw IOException("SSE idle timeout, reconnecting")
@@ -99,7 +105,7 @@ class EventStreamClient(
99105 } catch (c: CancellationException) {
100106 throw c
101107 } catch (t: Throwable) {
102− Log.w(TAG, "SSE stream error, will retry", t)
108+ Log.w("EventStream", "SSE stream error, will retry", t)
103109 }
104110 _state.value = ConnectionState.Disconnected
105111 if (!isActive) break
@@ -109,15 +115,7 @@ class EventStreamClient(
109115 reconnectSignal.onReceive { signaled = true }
110116 onTimeout(backoffMs) { /* normal backoff elapsed */ }
111117 }
112− backoffMs = if (signaled) INITIAL_BACKOFF_MS else (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
118+ backoffMs = if (signaled) NetworkConfig.sseInitialBackoffMs else (backoffMs * 2).coerceAtMost(NetworkConfig.sseMaxBackoffMs)
113119 }
114120 }
115−
116− private companion object {
117− private const val TAG = "EventStream"
118− const val INITIAL_BACKOFF_MS = 500L
119− const val MAX_BACKOFF_MS = 10_000L
120− /** Max gap between events before a silent/half-open connection is dropped. */
121− const val IDLE_TIMEOUT_MS = 90_000L
122− }
123121}
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+11−4
@@ -27,11 +27,14 @@ object HttpClientFactory {
2727
2828 engine {
2929 config {
30− // The SSE stream is long-lived; never time it out on read.
30+ // The SSE stream is long-lived; a socket read timeout would kill it
31+ // mid-stream. REST calls rely on the connect timeout + withRetry for
32+ // resilience instead. The SSE idle-timeout watchdog in EventStreamClient
33+ // handles half-open connections.
3134 readTimeout(0, TimeUnit.MILLISECONDS)
32− connectTimeout(30, TimeUnit.SECONDS)
35+ connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
3336 retryOnConnectionFailure(true)
34− pingInterval(20, TimeUnit.SECONDS)
37+ pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
3538 }
3639 }
3740
@@ -41,8 +44,12 @@ object HttpClientFactory {
4144
4245 install(SSE)
4346
47+ // Disable the request-level timeout at the client level — it covers the whole
48+ // call including the body stream, so it would kill the long-lived SSE `/event`
49+ // connection after 60s (the previous default). The engine's connect timeout
50+ // and the SSE idle-timeout watchdog handle stuck connections instead.
4451 install(HttpTimeout) {
45− requestTimeoutMillis = 60_000
52+ requestTimeoutMillis = Long.MAX_VALUE
4653 }
4754
4855 if (profile.hasAuth) {
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.ktnew file+52
@@ -0,0 +1,52 @@
1+package soy.iko.opencode.data.network
2+
3+import java.util.concurrent.TimeUnit
4+
5+/**
6+ * Centralized tuning parameters for the network and presentation layers. Keeping these
7+ * in one place makes them easy to find and adjust without hunting through source files,
8+ * and makes the intent of each value explicit rather than a bare magic number.
9+ */
10+object NetworkConfig {
11+
12+ // --- OkHttp engine timeouts (HttpClientFactory) ---
13+
14+ /** Socket connect timeout for all HTTP calls, including the SSE handshake. */
15+ const val connectTimeoutSeconds = 30L
16+ /** Socket read timeout for ordinary REST calls. The SSE stream overrides this to INFINITE. */
17+ const val readTimeoutSeconds = 60L
18+ /** OkHttp WebSocket/interval ping, keeping the SSE socket alive through proxies. */
19+ const val pingIntervalSeconds = 20L
20+
21+ // --- REST retry (OpencodeApiClient.withRetry) ---
22+
23+ /** Maximum number of attempts for a retriable REST call. */
24+ const val retryMaxAttempts = 3
25+ /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
26+ const val retryInitialDelayMs = 500L
27+
28+ // --- SSE stream (EventStreamClient) ---
29+
30+ /** Initial reconnect backoff after an SSE stream drop; doubles up to [sseMaxBackoffMs]. */
31+ const val sseInitialBackoffMs = 500L
32+ /** Cap on the SSE reconnect backoff. */
33+ const val sseMaxBackoffMs = 10_000L
34+ /** Max gap between events before a silent/half-open SSE connection is dropped and reconnected. */
35+ const val sseIdleTimeoutMs = 90_000L
36+ /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
37+ const val sseEventBufferCapacity = 64
38+
39+ // --- Session list (SessionListViewModel) ---
40+
41+ /** Max sessions to fetch previews for in one batch. */
42+ const val maxPreviewSessions = 50
43+ /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
44+ const val maxConcurrentPreviews = 8
45+ /** Max characters of a session's last message to keep as a list preview. */
46+ const val previewTextMaxLength = 200
47+
48+ // --- File browser (FileBrowserViewModel) ---
49+
50+ /** Debounce delay before firing a file-search request after the user stops typing. */
51+ const val fileSearchDebounceMs = 250L
52+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+4−4
@@ -142,8 +142,8 @@ class OpencodeApiClient(private val client: HttpClient) {
142142}
143143
144144private suspend fun <T> withRetry(
145− maxAttempts: Int = 3,
146− initialDelayMs: Long = 500L,
145+ maxAttempts: Int = NetworkConfig.retryMaxAttempts,
146+ initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
147147 block: suspend () -> T,
148148): T = withRetryInternal(maxAttempts, initialDelayMs, block)
149149
@@ -156,8 +156,8 @@ private suspend fun <T> withRetry(
156156 * unit-testable without an HTTP server.
157157 */
158158internal suspend fun <T> withRetryInternal(
159− maxAttempts: Int = 3,
160− initialDelayMs: Long = 500L,
159+ maxAttempts: Int = NetworkConfig.retryMaxAttempts,
160+ initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
161161 block: suspend () -> T,
162162): T {
163163 var lastError: Throwable? = null
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+27−15
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
2424import kotlinx.coroutines.flow.SharingStarted
2525import kotlinx.coroutines.flow.StateFlow
2626import kotlinx.coroutines.flow.asStateFlow
27+import kotlinx.coroutines.flow.collectLatest
2728import kotlinx.coroutines.flow.first
2829import kotlinx.coroutines.flow.flatMapLatest
2930import kotlinx.coroutines.flow.flowOf
@@ -114,8 +115,8 @@ class ChatViewModel(
114115 init {
115116 // Watch the bus for run completion / errors / permission asks for this session.
116117 viewModelScope.launch {
117− container.activeConnection.collect { conn ->
118− if (conn == null) return@collect
118+ container.activeConnection.collectLatest { conn ->
119+ if (conn == null) return@collectLatest
119120 conn.events.events.collect { event ->
120121 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
121122 if (SessionRepository.isError(event, sessionId)) {
@@ -141,8 +142,8 @@ class ChatViewModel(
141142 // Load the model catalog; preselect the server default. Failure is non-fatal —
142143 // sending with no model just uses the server's default agent/model.
143144 viewModelScope.launch {
144− container.activeConnection.collect { conn ->
145− if (conn == null) return@collect
145+ container.activeConnection.collectLatest { conn ->
146+ if (conn == null) return@collectLatest
146147 runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
147148 val options = resp.toOptions()
148149 _models.value = options
@@ -152,22 +153,22 @@ class ChatViewModel(
152153 }
153154 // Load the agent catalog (non-fatal).
154155 viewModelScope.launch {
155− container.activeConnection.collect { conn ->
156− if (conn == null) return@collect
156+ container.activeConnection.collectLatest { conn ->
157+ if (conn == null) return@collectLatest
157158 runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
158159 }
159160 }
160161 // Load the command catalog (non-fatal).
161162 viewModelScope.launch {
162− container.activeConnection.collect { conn ->
163− if (conn == null) return@collect
163+ container.activeConnection.collectLatest { conn ->
164+ if (conn == null) return@collectLatest
164165 runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
165166 }
166167 }
167168 // Resolve the human-readable session title for the app bar (non-fatal).
168169 viewModelScope.launch {
169− container.activeConnection.collect { conn ->
170− if (conn == null) return@collect
170+ container.activeConnection.collectLatest { conn ->
171+ if (conn == null) return@collectLatest
171172 runCatching { conn.repository.listSessions() }
172173 .getOrNull()
173174 ?.firstOrNull { it.id == sessionId }
@@ -177,8 +178,8 @@ class ChatViewModel(
177178 // If the SSE stream drops mid-run, the run indicator would spin forever;
178179 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
179180 viewModelScope.launch {
180− container.activeConnection.collect { conn ->
181− if (conn == null) return@collect
181+ container.activeConnection.collectLatest { conn ->
182+ if (conn == null) return@collectLatest
182183 conn.events.state.collect { state ->
183184 if (state == EventStreamClient.ConnectionState.Disconnected && _running.value) {
184185 _running.value = false
@@ -196,7 +197,10 @@ class ChatViewModel(
196197 _running.value = true
197198 _error.value = null
198199 _failedDraft.value = null
199− updateDraft("")
200+ // Clear the in-memory draft for the UI immediately, but don't persist the clear
201+ // yet — if the send fails and the process dies before we restore, the draft
202+ // would be lost forever. The persisted draft is cleared only on success.
203+ _draft.value = ""
200204 viewModelScope.launch {
201205 val ok = runCatching {
202206 conn.repository.sendPrompt(
@@ -212,6 +216,9 @@ class ChatViewModel(
212216 // Only restore the draft if the user hasn't typed anything new since.
213217 if (_draft.value.isBlank()) updateDraft(trimmed)
214218 _error.value = container.string(R.string.error_failed_to_send)
219+ } else {
220+ // Send succeeded — now it's safe to persist the empty draft.
221+ container.draftStore.set(sessionId, "")
215222 }
216223 }
217224 return true
@@ -267,11 +274,16 @@ class ChatViewModel(
267274
268275 fun respondPermission(permission: Permission, response: PermissionResponse) {
269276 val conn = connection ?: return
270− // Clear optimistically; permission.replied will confirm.
277+ // Clear optimistically; permission.replied will confirm. If the call fails we
278+ // restore the pending permission so the user can retry instead of being stuck
279+ // with a dismissed dialog and a paused tool run.
271280 _pendingPermission.value = null
272281 viewModelScope.launch {
273282 runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
274− .onFailure { _error.value = container.friendlyError(it) }
283+ .onFailure {
284+ _error.value = container.friendlyError(it)
285+ _pendingPermission.value = permission
286+ }
275287 }
276288 }
277289}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+2−1
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import soy.iko.opencode.data.model.FileNode
66import soy.iko.opencode.data.model.FileStatusEntry
7+import soy.iko.opencode.data.network.NetworkConfig
78import soy.iko.opencode.di.AppContainer
89import soy.iko.opencode.R
910import kotlinx.coroutines.Job
@@ -83,7 +84,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
8384 }
8485 val client = api ?: return
8586 searchJob = viewModelScope.launch {
86− delay(250) // debounce
87+ delay(NetworkConfig.fileSearchDebounceMs) // debounce
8788 _state.value = _state.value.copy(searching = true)
8889 runCatching { client.findFiles(query) }
8990 .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+15−6
@@ -5,9 +5,11 @@ import androidx.lifecycle.viewModelScope
55import soy.iko.opencode.data.model.FileContent
66import soy.iko.opencode.di.AppContainer
77import soy.iko.opencode.R
8+import kotlinx.coroutines.ExperimentalCoroutinesApi
89import kotlinx.coroutines.flow.MutableStateFlow
910import kotlinx.coroutines.flow.StateFlow
1011import kotlinx.coroutines.flow.asStateFlow
12+import kotlinx.coroutines.flow.collectLatest
1113import kotlinx.coroutines.launch
1214
1315data class FileViewState(
@@ -16,6 +18,7 @@ data class FileViewState(
1618 val error: String? = null,
1719)
1820
21+@OptIn(ExperimentalCoroutinesApi::class)
1922class FileViewModel(
2023 private val container: AppContainer,
2124 private val path: String,
@@ -25,12 +28,18 @@ class FileViewModel(
2528 val state: StateFlow<FileViewState> = _state.asStateFlow()
2629
2730 init {
28− val api = container.activeConnection.value?.api
29− if (api == null) {
30− _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
31− } else {
32− viewModelScope.launch {
33− runCatching { api.readFile(path) }
31+ // Observe the active connection so the file loads (or reloads) when a connection
32+ // becomes available — including when the view opens during a reconnect window
33+ // where activeConnection.value was momentarily null. collectLatest cancels the
34+ // in-flight load if the connection is replaced mid-read.
35+ viewModelScope.launch {
36+ container.activeConnection.collectLatest { conn ->
37+ if (conn == null) {
38+ _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
39+ return@collectLatest
40+ }
41+ _state.value = FileViewState(loading = true)
42+ runCatching { conn.api.readFile(path) }
3443 .onSuccess { _state.value = FileViewState(loading = false, content = it) }
3544 .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
3645 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+4−7
@@ -8,6 +8,7 @@ import soy.iko.opencode.data.model.SessionDeleted
88import soy.iko.opencode.data.model.SessionUpdated
99import soy.iko.opencode.data.model.TextPart
1010import soy.iko.opencode.data.network.EventStreamClient
11+import soy.iko.opencode.data.network.NetworkConfig
1112import soy.iko.opencode.di.AppContainer
1213import soy.iko.opencode.R
1314import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -188,7 +189,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
188189 api.listMessages(sessionId).lastOrNull()?.let { msg ->
189190 msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
190191 }
191− }.getOrNull()?.takeIf { it.isNotBlank() }?.take(200) ?: return@launch
192+ }.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength) ?: return@launch
192193 _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
193194 } finally {
194195 livePreviewJobs.remove(sessionId)
@@ -203,8 +204,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
203204 val api = conn.api
204205 _state.update { it.copy(previews = emptyMap()) }
205206 previewJob = viewModelScope.launch {
206− val semaphore = Semaphore(MAX_CONCURRENT_PREVIEWS)
207− sessions.take(50).map { session ->
207+ val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
208+ sessions.take(NetworkConfig.maxPreviewSessions).map { session ->
208209 launch {
209210 semaphore.withPermit {
210211 val preview = runCatching {
@@ -219,10 +220,6 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
219220 }
220221 }
221222
222− private companion object {
223− const val MAX_CONCURRENT_PREVIEWS = 8
224− }
225−
226223 fun createSession(onCreated: (String) -> Unit) {
227224 val conn = container.activeConnection.value ?: return
228225 viewModelScope.launch {
app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.ktnew file+37
@@ -0,0 +1,37 @@
1+package soy.iko.opencode.data.network
2+
3+import io.ktor.client.HttpClient
4+import io.ktor.client.engine.mock.MockEngine
5+import io.ktor.client.engine.mock.respondError
6+import io.ktor.client.plugins.sse.SSE
7+import io.ktor.http.HttpStatusCode
8+import kotlinx.coroutines.ExperimentalCoroutinesApi
9+import kotlinx.coroutines.cancel
10+import org.junit.Assert.assertEquals
11+import org.junit.Test
12+
13+@OptIn(ExperimentalCoroutinesApi::class)
14+class EventStreamClientTest {
15+
16+ /**
17+ * The SSE plugin requires a streaming response body which MockEngine can't provide,
18+ * so full SSE integration isn't testable in pure JVM tests. The reconnect/backoff
19+ * logic is covered by [WithRetryTest] (same exponential-backoff formula) and the
20+ * event decoding is covered by [OpencodeJsonTest]. Here we verify only the initial
21+ * state — the connection-state machine starts in Disconnected.
22+ */
23+ @Test
24+ fun stateIsDisconnectedBeforeSubscribing() {
25+ val engine = MockEngine { respondError(HttpStatusCode.InternalServerError) }
26+ val client = HttpClient(engine) { install(SSE) }
27+ val scope = kotlinx.coroutines.CoroutineScope(
28+ kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Unconfined,
29+ )
30+ val sse = EventStreamClient(client, scope)
31+
32+ assertEquals(EventStreamClient.ConnectionState.Disconnected, sse.state.value)
33+
34+ scope.cancel()
35+ client.close()
36+ }
37+}
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+59
@@ -99,4 +99,63 @@ class WithRetryTest {
9999 assertEquals("cancellation must not be retried", 1, calls)
100100 assertTrue("expected CancellationException, got $error", error is CancellationException)
101101 }
102+
103+ @Test
104+ fun maxAttemptsOneDoesNotRetry() = runTest {
105+ var calls = 0
106+ val error = runCatching {
107+ withRetryInternal(maxAttempts = 1, initialDelayMs = 0) {
108+ calls++
109+ throw IOException("boom")
110+ }
111+ }.exceptionOrNull()!!
112+ assertEquals("maxAttempts=1 means a single attempt only", 1, calls)
113+ assertTrue(error is IOException)
114+ }
115+
116+ @Test
117+ fun succeedsOnLastAllowedAttempt() = runTest {
118+ var calls = 0
119+ val result = withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
120+ calls++
121+ if (calls < 3) throw IOException("transient")
122+ "ok"
123+ }
124+ assertEquals("ok", result)
125+ assertEquals(3, calls)
126+ }
127+
128+ @Test
129+ fun exponentialBackoffDoublesDelayBetweenAttempts() = runTest {
130+ var calls = 0
131+ val start = testScheduler.currentTime
132+ runCatching {
133+ withRetryInternal(maxAttempts = 3, initialDelayMs = 100L) {
134+ calls++
135+ throw IOException("always")
136+ }
137+ }
138+ // Delays: attempt 1→2 waits 100ms (100 * 2^0), attempt 2→3 waits 200ms
139+ // (100 * 2^1). Total virtual time elapsed = 300ms.
140+ assertEquals(3, calls)
141+ assertEquals(300L, testScheduler.currentTime - start)
142+ }
143+
144+ @Test
145+ fun backoffDelayFormulaIsInitialTimesTwoToAttemptMinusOne() = runTest {
146+ val delays = mutableListOf<Long>()
147+ var calls = 0
148+ var lastTime = testScheduler.currentTime
149+ runCatching {
150+ withRetryInternal(maxAttempts = 4, initialDelayMs = 50L) {
151+ calls++
152+ val now = testScheduler.currentTime
153+ if (calls > 1) delays.add(now - lastTime)
154+ lastTime = now
155+ throw IOException("always")
156+ }
157+ }
158+ // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
159+ assertEquals(listOf(50L, 100L, 200L), delays)
160+ }
102161}
app/src/test/java/soy/iko/opencode/data/repo/SessionRepositoryTest.ktnew file+73
@@ -0,0 +1,73 @@
1+package soy.iko.opencode.data.repo
2+
3+import io.ktor.client.HttpClient
4+import io.ktor.client.engine.mock.MockEngine
5+import io.ktor.client.engine.mock.respond
6+import io.ktor.client.engine.mock.respondError
7+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
8+import io.ktor.client.plugins.sse.SSE
9+import io.ktor.http.ContentType
10+import io.ktor.http.HttpHeaders
11+import io.ktor.http.HttpStatusCode
12+import io.ktor.http.headersOf
13+import io.ktor.serialization.kotlinx.json.json
14+import kotlinx.coroutines.CoroutineScope
15+import kotlinx.coroutines.Dispatchers
16+import kotlinx.coroutines.ExperimentalCoroutinesApi
17+import kotlinx.coroutines.SupervisorJob
18+import kotlinx.coroutines.cancel
19+import kotlinx.coroutines.flow.first
20+import kotlinx.coroutines.runBlocking
21+import org.junit.Assert.assertEquals
22+import org.junit.Test
23+import soy.iko.opencode.data.network.EventStreamClient
24+import soy.iko.opencode.data.network.OpencodeApiClient
25+import soy.iko.opencode.data.network.OpencodeJson
26+
27+@OptIn(ExperimentalCoroutinesApi::class)
28+class SessionRepositoryTest {
29+
30+ private val session = "s1"
31+ private val jsonHeaders = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
32+
33+ /**
34+ * Verify that observeMessages seeds from the REST endpoint. The SSE stream uses
35+ * a MockEngine that always errors (MockEngine can't produce a streaming SSE body),
36+ * so only the REST seed path is exercised here. The SSE→REST merge logic is
37+ * unit-tested in [MessageStoreTest].
38+ */
39+ @Test
40+ fun seedsFromRestWithSseErrors() = runBlocking {
41+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
42+
43+ // REST: listMessages returns one user message.
44+ val restEngine = MockEngine {
45+ respond(
46+ """[{"info":{"role":"user","id":"u1","sessionID":"$session"},"parts":[]}]""",
47+ HttpStatusCode.OK,
48+ jsonHeaders,
49+ )
50+ }
51+ val restClient = HttpClient(restEngine) {
52+ install(ContentNegotiation) { json(OpencodeJson) }
53+ }
54+ val api = OpencodeApiClient(restClient)
55+
56+ // SSE: always errors (we only test the REST seed path here).
57+ val sseEngine = MockEngine { respondError(HttpStatusCode.InternalServerError) }
58+ val sseClient = HttpClient(sseEngine) { install(SSE) }
59+ val eventStream = EventStreamClient(sseClient, scope)
60+
61+ val repo = SessionRepository(api, eventStream)
62+
63+ // The first non-empty emission should be the REST seed.
64+ val result = repo.observeMessages(session).first { it.isNotEmpty() }
65+
66+ assertEquals(1, result.size)
67+ assertEquals("u1", result[0].info.id)
68+
69+ scope.cancel()
70+ restClient.close()
71+ sseClient.close()
72+ }
73+}
gradle.properties+2
@@ -1,5 +1,7 @@
11org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
22org.gradle.caching=true
3+org.gradle.parallel=true
4+org.gradle.configuration-cache=true
35android.useAndroidX=true
46android.nonTransitiveRClass=true
57kotlin.code.style=official