package soy.iko.opencode.data.network import soy.iko.opencode.data.model.Agent import soy.iko.opencode.data.model.Command import soy.iko.opencode.data.model.CommandRequest import soy.iko.opencode.data.model.CreateSessionRequest import soy.iko.opencode.data.model.FileContent import soy.iko.opencode.data.model.FileNode import soy.iko.opencode.data.model.FileStatusEntry import soy.iko.opencode.data.model.MessageWithParts import soy.iko.opencode.data.model.ModelRef import soy.iko.opencode.data.model.PermissionReplyBody import soy.iko.opencode.data.model.PermissionResponse import soy.iko.opencode.data.model.PromptPart import soy.iko.opencode.data.model.PromptRequest import soy.iko.opencode.data.model.ProvidersResponse import soy.iko.opencode.data.model.Session import soy.iko.opencode.data.model.UpdateSessionRequest import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.ClientRequestException import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.patch import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType import kotlinx.coroutines.delay /** * Request/response wrapper over the opencode REST endpoints. The long-lived `/event` * SSE stream lives in [EventStreamClient]; everything else is here. */ class OpencodeApiClient(private val client: HttpClient) { /** Lightweight connectivity check. Throws on non-2xx / network failure. */ suspend fun ping() { client.get("global/health") } suspend fun listSessions(): List = withRetry { client.get("session").body() } suspend fun createSession(title: String? = null): Session = client.post("session") { contentType(ContentType.Application.Json) setBody(CreateSessionRequest(title = title)) }.body() suspend fun updateSession(id: String, title: String): Session = client.patch("session/$id") { contentType(ContentType.Application.Json) setBody(UpdateSessionRequest(title = title)) }.body() suspend fun deleteSession(id: String) { client.delete("session/$id") } suspend fun listMessages(sessionId: String): List = withRetry { client.get("session/$sessionId/message").body() } suspend fun sendPrompt( sessionId: String, text: String, model: ModelRef? = null, agent: String? = null, ): MessageWithParts = client.post("session/$sessionId/message") { contentType(ContentType.Application.Json) setBody( PromptRequest( parts = listOf(PromptPart(text = text)), model = model, agent = agent, ), ) }.body() suspend fun abort(sessionId: String) { client.post("session/$sessionId/abort") } /** Invoke a slash-command by name via `POST /session/:id/command`. */ suspend fun runCommand( sessionId: String, command: String, arguments: String = "", agent: String? = null, ): MessageWithParts = client.post("session/$sessionId/command") { contentType(ContentType.Application.Json) setBody(CommandRequest(command = command, arguments = arguments, agent = agent)) }.body() suspend fun providers(): ProvidersResponse = withRetry { client.get("config/providers").body() } suspend fun agents(): List = withRetry { client.get("agent").body() } suspend fun commands(): List = withRetry { client.get("command").body() } /** Respond to a permission request so a paused tool run can proceed. */ suspend fun respondPermission( sessionId: String, permissionId: String, response: PermissionResponse, ) = withRetry { client.post("session/$sessionId/permissions/$permissionId") { contentType(ContentType.Application.Json) setBody(PermissionReplyBody(response.wire)) } } // --- Files --- suspend fun findFiles(query: String): List = withRetry { client.get("find/file") { parameter("query", query) }.body() } suspend fun listDirectory(path: String): List = withRetry { client.get("file") { parameter("path", path) }.body() } suspend fun readFile(path: String): FileContent = withRetry { client.get("file/content") { parameter("path", path) }.body() } suspend fun fileStatus(): List = withRetry { client.get("file/status").body() } } private suspend fun withRetry( maxAttempts: Int = NetworkConfig.retryMaxAttempts, initialDelayMs: Long = NetworkConfig.retryInitialDelayMs, block: suspend () -> T, ): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block) /** * Retry a suspending block up to [maxAttempts] times with exponential backoff. * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx * server errors are retried. Exposed as `internal` so the retry/skip rules are * unit-testable without an HTTP server. */ internal suspend fun withRetryInternal( maxAttempts: Int = NetworkConfig.retryMaxAttempts, initialDelayMs: Long = NetworkConfig.retryInitialDelayMs, jitterFactor: Double = NetworkConfig.retryJitterFactor, block: suspend () -> T, ): T { var lastError: Throwable? = null for (attempt in 1..maxAttempts) { try { return block() } catch (c: kotlinx.coroutines.CancellationException) { throw c } catch (t: Throwable) { lastError = t if (t is ClientRequestException) throw t if (attempt < maxAttempts) { val baseDelay = initialDelayMs * (1 shl (attempt - 1)) val jitter = (baseDelay * jitterFactor * Math.random()).toLong() delay(baseDelay + jitter) } } } throw lastError ?: IllegalStateException("withRetry failed without error") }