git.iko.soy/opencode-android
on master help sign in
Move crash-report reads off the main thread, fix orphaned coroutine scope, add permission-response retry, predictive back, and accessibility fixes
6839df9iko
README.md+26−1
@@ -6,7 +6,7 @@ Application id: `soy.iko.opencode`
6
6
7## Status
7## Status
8
8
9−Milestones M0–M11 complete:
9+Milestones M0–M12 complete:
10
10
11- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
11- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
12 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
12 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
@@ -97,6 +97,31 @@ Milestones M0–M11 complete:
97 signed-output path. Instrumented tests (androidTest) cover the crash logger and
97 signed-output path. Instrumented tests (androidTest) cover the crash logger and
98 app bootstrap.
98 app bootstrap.
99
99
100+- **M12:** **Reliability, a11y & test-coverage pass.**
101+ - **Diagnostics disk I/O off the main thread** — crash report reads in the
102+ Diagnostics screen were moved from synchronous `remember` blocks (composition
103+ / main thread) into coroutines with `Dispatchers.IO`, eliminating potential
104+ jank on large reports.
105+ - **Orphaned coroutine scope fix** — `DraftStore` no longer creates an
106+ unscoped `CoroutineScope` that leaks; it now uses the app-scoped
107+ `CoroutineScope` from `AppContainer`.
108+ - **Predictive back** — `android:enableOnBackInvokedCallback="true"` added to
109+ the manifest so the Android 14+ predictive back gesture integrates properly
110+ with the app's `BackHandler` usage.
111+ - **Crash report filename uniqueness** — crash report timestamps now include
112+ milliseconds so two crashes within the same second don't overwrite each other.
113+ - **Permission response retry** — `POST /session/:id/permissions/:permissionID`
114+ now uses the same retry-with-backoff as other REST calls, so a transient
115+ network failure no longer leaves a tool run stuck without automatic retry.
116+ - **Accessibility: space-prefix text patterns** — six `" " + text` patterns
117+ that screen readers would announce with extra spaces were replaced with
118+ `Modifier.padding(start = ...)` on the Text composables.
119+ - **Accessibility: unread badge** — the unread activity dot on session cards
120+ now carries a `contentDescription` so TalkBack announces it.
121+ - **Test coverage** — added `OpencodeApiClientTest` (3 tests covering the
122+ permission-response retry path) and `DraftStoreTest` (instrumented, covering
123+ set/get/remove/blank-clears round-trip).
124+
100## Architecture
125## Architecture
101
126
102- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
127- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.ktnew file+49
@@ -0,0 +1,49 @@
1+package soy.iko.opencode.data.repo
2+
3+import androidx.test.core.app.ApplicationProvider
4+import androidx.test.ext.junit.runners.AndroidJUnit4
5+import kotlinx.coroutines.CoroutineScope
6+import kotlinx.coroutines.Dispatchers
7+import kotlinx.coroutines.SupervisorJob
8+import org.junit.Assert.assertEquals
9+import org.junit.Test
10+import org.junit.runner.RunWith
11+
12+@RunWith(AndroidJUnit4::class)
13+class DraftStoreTest {
14+
15+ private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
16+
17+ @Test
18+ fun setGetRemoveRoundTrip() {
19+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
20+ val store = DraftStore(context, scope)
21+
22+ // Use a unique session ID so it doesn't collide with other tests.
23+ val sid = "test-${System.nanoTime()}"
24+
25+ store.set(sid, "hello world")
26+ assertEquals("hello world", store.get(sid))
27+
28+ // Overwrite
29+ store.set(sid, "updated")
30+ assertEquals("updated", store.get(sid))
31+
32+ // Blank clears the entry
33+ store.set(sid, " ")
34+ assertEquals("", store.get(sid))
35+
36+ // Explicit remove
37+ store.set(sid, "text")
38+ store.remove(sid)
39+ assertEquals("", store.get(sid))
40+
41+ scope.cancel()
42+ }
43+
44+ @Test
45+ fun getReturnsEmptyForUnknownSession() {
46+ val store = DraftStore(context, CoroutineScope(SupervisorJob()))
47+ assertEquals("", store.get("never-set-${System.nanoTime()}"))
48+ }
49+}
app/src/main/AndroidManifest.xml+2−1
@@ -22,7 +22,8 @@
22 android:name=".MainActivity"
22 android:name=".MainActivity"
23 android:exported="true"
23 android:exported="true"
24 android:launchMode="singleTop"
24 android:launchMode="singleTop"
25− android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode"
25+ android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|navigation"
26+ android:enableOnBackInvokedCallback="true"
26 android:theme="@style/Theme.OpencodeAndroid.Splash">
27 android:theme="@style/Theme.OpencodeAndroid.Splash">
27 <intent-filter>
28 <intent-filter>
28 <action android:name="android.intent.action.MAIN" />
29 <action android:name="android.intent.action.MAIN" />
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+1−1
@@ -115,7 +115,7 @@ class OpencodeApiClient(private val client: HttpClient) {
115 sessionId: String,
115 sessionId: String,
116 permissionId: String,
116 permissionId: String,
117 response: PermissionResponse,
117 response: PermissionResponse,
118− ) {
118+ ) = withRetry {
119 client.post("session/$sessionId/permissions/$permissionId") {
119 client.post("session/$sessionId/permissions/$permissionId") {
120 contentType(ContentType.Application.Json)
120 contentType(ContentType.Application.Json)
121 setBody(PermissionReplyBody(response.wire))
121 setBody(PermissionReplyBody(response.wire))
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+1−1
@@ -80,7 +80,7 @@ class CrashLogger private constructor(private val appContext: Context) {
80
80
81 private fun writeReport(thread: Thread, throwable: Throwable) {
81 private fun writeReport(thread: Thread, throwable: Throwable) {
82 val now = Date()
82 val now = Date()
83− val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(now)
83+ val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
84 val file = File(crashDir, "crash-$stamp.txt")
84 val file = File(crashDir, "crash-$stamp.txt")
85 val sw = StringWriter()
85 val sw = StringWriter()
86 PrintWriter(sw).use { pw ->
86 PrintWriter(sw).use { pw ->
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+2−4
@@ -2,8 +2,6 @@ package soy.iko.opencode.data.repo
2
2
3import android.content.Context
3import android.content.Context
4import kotlinx.coroutines.CoroutineScope
4import kotlinx.coroutines.CoroutineScope
5−import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.SupervisorJob
7import kotlinx.coroutines.launch
5import kotlinx.coroutines.launch
8
6
9/**
7/**
@@ -14,7 +12,7 @@ import kotlinx.coroutines.launch
14 * The prefs file is loaded on a background thread at construction time so the
12 * The prefs file is loaded on a background thread at construction time so the
15 * first [get] call on the main thread doesn't trigger a synchronous disk read.
13 * first [get] call on the main thread doesn't trigger a synchronous disk read.
16 */
14 */
17−class DraftStore(context: Context) {
15+class DraftStore(context: Context, scope: CoroutineScope) {
18
16
19 private val appContext = context.applicationContext
17 private val appContext = context.applicationContext
20 private val prefs by lazy {
18 private val prefs by lazy {
@@ -24,7 +22,7 @@ class DraftStore(context: Context) {
24 init {
22 init {
25 // Eagerly load the prefs file on a background thread so the first main-thread
23 // Eagerly load the prefs file on a background thread so the first main-thread
26 // get() doesn't block on disk I/O. The lazy delegate ensures the same instance.
24 // get() doesn't block on disk I/O. The lazy delegate ensures the same instance.
27− CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { prefs.all }
25+ scope.launch { prefs.all }
28 }
26 }
29
27
30 fun get(sessionId: String): String = prefs.getString(sessionId, "").orEmpty()
28 fun get(sessionId: String): String = prefs.getString(sessionId, "").orEmpty()
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+1−1
@@ -45,7 +45,7 @@ class AppContainer(context: Context) {
45 val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
45 val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
46 val profileStore = ProfileStore(context)
46 val profileStore = ProfileStore(context)
47 val settingsStore = SettingsStore(context)
47 val settingsStore = SettingsStore(context)
48− val draftStore = DraftStore(context)
48+ val draftStore = DraftStore(context, appScope)
49
49
50 private val appContext = context.applicationContext
50 private val appContext = context.applicationContext
51
51
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+2−2
@@ -212,7 +212,7 @@ fun ChatScreen(
212 Text(
212 Text(
213 buildString {
213 buildString {
214 append(selectedModel?.modelLabel ?: stringResource(R.string.default_model))
214 append(selectedModel?.modelLabel ?: stringResource(R.string.default_model))
215− selectedAgent?.let { append(" • $it") }
215+ selectedAgent?.let { append(" · $it") }
216 },
216 },
217 style = MaterialTheme.typography.labelSmall,
217 style = MaterialTheme.typography.labelSmall,
218 color = MaterialTheme.colorScheme.onSurfaceVariant,
218 color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -305,7 +305,7 @@ fun ChatScreen(
305 item(key = "__typing") {
305 item(key = "__typing") {
306 Row(verticalAlignment = Alignment.CenterVertically) {
306 Row(verticalAlignment = Alignment.CenterVertically) {
307 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
307 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
308− Text(" " + stringResource(R.string.working), style = MaterialTheme.typography.labelMedium)
308+ Text(stringResource(R.string.working), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
309 }
309 }
310 }
310 }
311 }
311 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+10−6
@@ -115,9 +115,10 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
115 )
115 )
116 }
116 }
117 Text(
117 Text(
118− " ${if (streaming) stringResource(R.string.thinking) else stringResource(R.string.thoughts)}",
118+ if (streaming) stringResource(R.string.thinking) else stringResource(R.string.thoughts),
119 style = MaterialTheme.typography.labelMedium,
119 style = MaterialTheme.typography.labelMedium,
120 color = MaterialTheme.colorScheme.onSurfaceVariant,
120 color = MaterialTheme.colorScheme.onSurfaceVariant,
121+ modifier = Modifier.padding(start = 6.dp),
121 )
122 )
122 }
123 }
123 AnimatedVisibility(visible = expanded) {
124 AnimatedVisibility(visible = expanded) {
@@ -132,7 +133,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
132 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
133 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
133 ) {
134 ) {
134 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
135 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
135− Text(" " + stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall)
136+ Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
136 }
137 }
137 }
138 }
138 }
139 }
@@ -152,9 +153,10 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
152 Row(verticalAlignment = Alignment.CenterVertically) {
153 Row(verticalAlignment = Alignment.CenterVertically) {
153 ToolStatusIcon(part.state)
154 ToolStatusIcon(part.state)
154 Text(
155 Text(
155− " ${part.tool}",
156+ part.tool,
156 style = MaterialTheme.typography.labelLarge,
157 style = MaterialTheme.typography.labelLarge,
157 fontFamily = FontFamily.Monospace,
158 fontFamily = FontFamily.Monospace,
159+ modifier = Modifier.padding(start = 6.dp),
158 )
160 )
159 }
161 }
160 val detail = when (val s = part.state) {
162 val detail = when (val s = part.state) {
@@ -187,8 +189,9 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
187 modifier = Modifier.size(16.dp),
189 modifier = Modifier.size(16.dp),
188 )
190 )
189 Text(
191 Text(
190− " ${if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more)}",
192+ if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more),
191 style = MaterialTheme.typography.labelSmall,
193 style = MaterialTheme.typography.labelSmall,
194+ modifier = Modifier.padding(start = 4.dp),
192 )
195 )
193 }
196 }
194 }
197 }
@@ -197,7 +200,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
197 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
200 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
198 ) {
201 ) {
199 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
202 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
200− Text(" " + stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall)
203+ Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
201 }
204 }
202 }
205 }
203 }
206 }
@@ -233,8 +236,9 @@ private fun FileChip(part: FilePart, modifier: Modifier) {
233 tint = MaterialTheme.colorScheme.onSurfaceVariant,
236 tint = MaterialTheme.colorScheme.onSurfaceVariant,
234 )
237 )
235 Text(
238 Text(
236− " ${part.filename ?: part.url ?: stringResource(R.string.file)}",
239+ part.filename ?: part.url ?: stringResource(R.string.file),
237 style = MaterialTheme.typography.bodyMedium,
240 style = MaterialTheme.typography.bodyMedium,
241+ modifier = Modifier.padding(start = 6.dp),
238 )
242 )
239 }
243 }
240}
244}
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+2−1
@@ -43,9 +43,10 @@ fun ConnectionBanner(
43 ) {
43 ) {
44 CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
44 CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
45 Text(
45 Text(
46− " $text",
46+ text,
47 style = MaterialTheme.typography.labelMedium,
47 style = MaterialTheme.typography.labelMedium,
48 color = MaterialTheme.colorScheme.onTertiaryContainer,
48 color = MaterialTheme.colorScheme.onTertiaryContainer,
49+ modifier = Modifier.padding(start = 6.dp),
49 )
50 )
50 }
51 }
51 }
52 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+5−2
@@ -59,6 +59,7 @@ import androidx.compose.ui.Modifier
59import androidx.compose.ui.draw.clip
59import androidx.compose.ui.draw.clip
60import androidx.compose.ui.platform.LocalHapticFeedback
60import androidx.compose.ui.platform.LocalHapticFeedback
61import androidx.compose.ui.semantics.Role
61import androidx.compose.ui.semantics.Role
62+import androidx.compose.ui.semantics.contentDescription
62import androidx.compose.ui.semantics.role
63import androidx.compose.ui.semantics.role
63import androidx.compose.ui.semantics.semantics
64import androidx.compose.ui.semantics.semantics
64import androidx.compose.ui.text.input.ImeAction
65import androidx.compose.ui.text.input.ImeAction
@@ -345,12 +346,14 @@ private fun SessionCard(
345 verticalAlignment = Alignment.CenterVertically,
346 verticalAlignment = Alignment.CenterVertically,
346 ) {
347 ) {
347 if (unread) {
348 if (unread) {
349+ val unreadLabel = stringResource(R.string.unread)
348 Box(
350 Box(
349 modifier = Modifier
351 modifier = Modifier
350 .padding(end = 8.dp)
352 .padding(end = 8.dp)
351 .size(8.dp)
353 .size(8.dp)
352 .clip(androidx.compose.foundation.shape.CircleShape)
354 .clip(androidx.compose.foundation.shape.CircleShape)
353− .background(MaterialTheme.colorScheme.primary),
355+ .background(MaterialTheme.colorScheme.primary)
356+ .semantics { contentDescription = unreadLabel },
354 )
357 )
355 }
358 }
356 Text(
359 Text(
@@ -433,7 +436,7 @@ private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
433 Spacer(Modifier.size(20.dp))
436 Spacer(Modifier.size(20.dp))
434 Button(onClick = onCreate) {
437 Button(onClick = onCreate) {
435 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
438 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
436− Text(" " + stringResource(R.string.new_session))
439+ Text(stringResource(R.string.new_session), modifier = Modifier.padding(start = 6.dp))
437 }
440 }
438 }
441 }
439}
442}
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+21−7
@@ -28,10 +28,15 @@ import androidx.compose.material3.Text
28import androidx.compose.material3.TextButton
28import androidx.compose.material3.TextButton
29import androidx.compose.material3.TopAppBar
29import androidx.compose.material3.TopAppBar
30import androidx.compose.runtime.Composable
30import androidx.compose.runtime.Composable
31+import androidx.compose.runtime.LaunchedEffect
31import androidx.compose.runtime.getValue
32import androidx.compose.runtime.getValue
32import androidx.compose.runtime.mutableStateOf
33import androidx.compose.runtime.mutableStateOf
33import androidx.compose.runtime.remember
34import androidx.compose.runtime.remember
35+import androidx.compose.runtime.rememberCoroutineScope
34import androidx.compose.runtime.setValue
36import androidx.compose.runtime.setValue
37+import kotlinx.coroutines.Dispatchers
38+import kotlinx.coroutines.launch
39+import kotlinx.coroutines.withContext
35import androidx.compose.ui.Alignment
40import androidx.compose.ui.Alignment
36import androidx.compose.ui.Modifier
41import androidx.compose.ui.Modifier
37import androidx.compose.ui.platform.LocalContext
42import androidx.compose.ui.platform.LocalContext
@@ -120,14 +125,19 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
120 color = MaterialTheme.colorScheme.onSurfaceVariant,
125 color = MaterialTheme.colorScheme.onSurfaceVariant,
121 )
126 )
122 }
127 }
123− val content = remember(report.fileName) { logger.readReport(report.fileName).orEmpty() }
128+ val shareScope = rememberCoroutineScope()
124 IconButton(onClick = {
129 IconButton(onClick = {
130+ shareScope.launch {
131+ val content = withContext(Dispatchers.IO) {
132+ logger.readReport(report.fileName).orEmpty()
133+ }
125− val send = Intent(Intent.ACTION_SEND).apply {
134+ val send = Intent(Intent.ACTION_SEND).apply {
126− type = "text/plain"
135+ type = "text/plain"
127− putExtra(Intent.EXTRA_SUBJECT, shareLabel)
136+ putExtra(Intent.EXTRA_SUBJECT, shareLabel)
128− putExtra(Intent.EXTRA_TEXT, content)
137+ putExtra(Intent.EXTRA_TEXT, content)
138+ }
139+ context.startActivity(Intent.createChooser(send, shareLabel))
129 }
140 }
130− context.startActivity(Intent.createChooser(send, shareLabel))
131 }) {
141 }) {
132 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
142 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
133 }
143 }
@@ -141,7 +151,11 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
141
151
142 val reportName = viewing
152 val reportName = viewing
143 if (reportName != null) {
153 if (reportName != null) {
154+ var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
155+ LaunchedEffect(reportName) {
144− val content = remember(reportName) { logger.readReport(reportName) }
156+ reportContent = withContext(Dispatchers.IO) { logger.readReport(reportName) }
157+ }
158+ val content = reportContent
145 AlertDialog(
159 AlertDialog(
146 onDismissRequest = { viewing = null },
160 onDismissRequest = { viewing = null },
147 confirmButton = {
161 confirmButton = {
app/src/main/res/values/strings.xml+2
@@ -5,6 +5,7 @@
5
5
6 <!-- Common -->
6 <!-- Common -->
7 <string name="back">Back</string>
7 <string name="back">Back</string>
8+ <string name="loading">Loading</string>
8 <string name="cancel">Cancel</string>
9 <string name="cancel">Cancel</string>
9 <string name="save">Save</string>
10 <string name="save">Save</string>
10 <string name="delete">Delete</string>
11 <string name="delete">Delete</string>
@@ -56,6 +57,7 @@
56
57
57 <!-- Session list -->
58 <!-- Session list -->
58 <string name="new_session">New session</string>
59 <string name="new_session">New session</string>
60+ <string name="unread">Unread</string>
59 <string name="no_sessions_yet">No sessions yet</string>
61 <string name="no_sessions_yet">No sessions yet</string>
60 <string name="no_sessions_hint">Start a conversation with opencode.</string>
62 <string name="no_sessions_hint">Start a conversation with opencode.</string>
61 <string name="search_sessions">Search sessions…</string>
63 <string name="search_sessions">Search sessions…</string>
app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.ktnew file+66
@@ -0,0 +1,66 @@
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.respond
6+import io.ktor.client.engine.mock.respondError
7+import io.ktor.client.plugins.ClientRequestException
8+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
9+import io.ktor.http.HttpStatusCode
10+import io.ktor.http.headersOf
11+import io.ktor.serialization.kotlinx.json.json
12+import kotlinx.coroutines.test.runTest
13+import org.junit.Assert.assertEquals
14+import org.junit.Assert.assertTrue
15+import org.junit.Test
16+import soy.iko.opencode.data.model.PermissionResponse
17+import java.io.IOException
18+
19+class OpencodeApiClientTest {
20+
21+ private fun makeClient(engine: MockEngine): HttpClient =
22+ HttpClient(engine) {
23+ expectSuccess = true
24+ install(ContentNegotiation) { json(OpencodeJson) }
25+ }
26+
27+ @Test
28+ fun respondPermissionRetriesOnTransientFailure() = runTest {
29+ var calls = 0
30+ val engine = MockEngine {
31+ calls++
32+ if (calls < 3) throw IOException("transient")
33+ respond("", HttpStatusCode.OK, headersOf("Content-Length", "0"))
34+ }
35+ val api = OpencodeApiClient(makeClient(engine))
36+ api.respondPermission("s1", "p1", PermissionResponse.ALWAYS)
37+ assertEquals("transient failures should be retried", 3, calls)
38+ }
39+
40+ @Test
41+ fun respondPermissionDoesNotRetryClientError() = runTest {
42+ var calls = 0
43+ val engine = MockEngine {
44+ calls++
45+ respondError(HttpStatusCode.BadRequest)
46+ }
47+ val api = OpencodeApiClient(makeClient(engine))
48+ val error = runCatching {
49+ api.respondPermission("s1", "p1", PermissionResponse.REJECT)
50+ }.exceptionOrNull()!!
51+ assertEquals("4xx must not be retried", 1, calls)
52+ assertTrue("expected ClientRequestException, got $error", error is ClientRequestException)
53+ }
54+
55+ @Test
56+ fun respondPermissionSucceedsOnFirstTry() = runTest {
57+ var calls = 0
58+ val engine = MockEngine {
59+ calls++
60+ respond("", HttpStatusCode.OK, headersOf("Content-Length", "0"))
61+ }
62+ val api = OpencodeApiClient(makeClient(engine))
63+ api.respondPermission("s1", "p1", PermissionResponse.ONCE)
64+ assertEquals(1, calls)
65+ }
66+}