@@ -29,6 +29,7 @@ import io.ktor.http.ContentType
29import io.ktor.http.contentType
29import io.ktor.http.contentType
30import kotlinx.coroutines.delay
30import kotlinx.coroutines.delay
31import kotlin.random.Random
31import kotlin.random.Random
32+import java.net.URLEncoder
34 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
35 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
@@ -52,17 +53,17 @@ class OpencodeApiClient(private val client: HttpClient) {
54 suspend fun updateSession(id: String, title: String): Session =
55 suspend fun updateSession(id: String, title: String): Session =
55− client.patch("session/$id") {
56+ client.patch("session/${encode(id)}") {
56 contentType(ContentType.Application.Json)
57 contentType(ContentType.Application.Json)
57 setBody(UpdateSessionRequest(title = title))
58 setBody(UpdateSessionRequest(title = title))
60 suspend fun deleteSession(id: String) {
61 suspend fun deleteSession(id: String) {
61− client.delete("session/$id")
62+ client.delete("session/${encode(id)}")
64 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
65 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
65− client.get("session/$sessionId/message").body()
66+ client.get("session/${encode(sessionId)}/message").body()
68 suspend fun sendPrompt(
69 suspend fun sendPrompt(
@@ -71,7 +72,7 @@ class OpencodeApiClient(private val client: HttpClient) {
71 model: ModelRef? = null,
72 model: ModelRef? = null,
72 agent: String? = null,
73 agent: String? = null,
73 ): MessageWithParts =
74 ): MessageWithParts =
74− client.post("session/$sessionId/message") {
75+ client.post("session/${encode(sessionId)}/message") {
75 contentType(ContentType.Application.Json)
76 contentType(ContentType.Application.Json)
77 PromptRequest(
78 PromptRequest(
@@ -83,7 +84,7 @@ class OpencodeApiClient(private val client: HttpClient) {
85 suspend fun abort(sessionId: String) {
86 suspend fun abort(sessionId: String) {
86− client.post("session/$sessionId/abort")
87+ client.post("session/${encode(sessionId)}/abort")
89 /** Invoke a slash-command by name via `POST /session/:id/command`. */
90 /** Invoke a slash-command by name via `POST /session/:id/command`. */
@@ -93,7 +94,7 @@ class OpencodeApiClient(private val client: HttpClient) {
93 arguments: String = "",
94 arguments: String = "",
94 agent: String? = null,
95 agent: String? = null,
95 ): MessageWithParts =
96 ): MessageWithParts =
96− client.post("session/$sessionId/command") {
97+ client.post("session/${encode(sessionId)}/command") {
97 contentType(ContentType.Application.Json)
98 contentType(ContentType.Application.Json)
98 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
99 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
@@ -116,7 +117,7 @@ class OpencodeApiClient(private val client: HttpClient) {
116 permissionId: String,
117 permissionId: String,
117 response: PermissionResponse,
118 response: PermissionResponse,
118 ) = withRetry {
119 ) = withRetry {
119− client.post("session/$sessionId/permissions/$permissionId") {
120+ client.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
120 contentType(ContentType.Application.Json)
121 contentType(ContentType.Application.Json)
121 setBody(PermissionReplyBody(response.wire))
122 setBody(PermissionReplyBody(response.wire))
@@ -169,7 +170,10 @@ internal suspend fun <T> withRetryInternal(
170 } catch (t: Exception) {
171 } catch (t: Exception) {
171 lastError = t
172 lastError = t
172− if (t is ClientRequestException) throw t
173+ if (t is ClientRequestException) {
174+ // 429 (Too Many Requests) is transient — retry with backoff.
175+ if (t.response.status.value != 429) throw t
173 if (attempt < maxAttempts) {
177 if (attempt < maxAttempts) {
174 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
178 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
175 val jitter = (baseDelay * jitterFactor * Random.nextDouble()).toLong()
179 val jitter = (baseDelay * jitterFactor * Random.nextDouble()).toLong()
@@ -179,3 +183,7 @@ internal suspend fun <T> withRetryInternal(
180 throw lastError ?: IllegalStateException("withRetry failed without error")
184 throw lastError ?: IllegalStateException("withRetry failed without error")
187+/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
188+private fun encode(segment: String): String =
189+ URLEncoder.encode(segment, "UTF-8").replace("+", "%20")