@@ -21,6 +21,7 @@ import io.ktor.client.call.body
21import io.ktor.client.plugins.ClientRequestException
21import io.ktor.client.plugins.ClientRequestException
22import io.ktor.client.request.delete
22import io.ktor.client.request.delete
23import io.ktor.client.request.get
23import io.ktor.client.request.get
24+import io.ktor.client.request.header
24import io.ktor.client.request.parameter
25import io.ktor.client.request.parameter
25import io.ktor.client.request.patch
26import io.ktor.client.request.patch
26import io.ktor.client.request.post
27import io.ktor.client.request.post
@@ -28,6 +29,8 @@ import io.ktor.client.request.setBody
28import io.ktor.http.ContentType
29import io.ktor.http.ContentType
29import io.ktor.http.contentType
30import io.ktor.http.contentType
30import kotlinx.coroutines.delay
31import kotlinx.coroutines.delay
32+import kotlinx.coroutines.sync.Mutex
33+import kotlinx.coroutines.sync.withLock
31import kotlin.random.Random
34import kotlin.random.Random
32import java.net.URLEncoder
35import java.net.URLEncoder
@@ -37,6 +40,16 @@ import java.net.URLEncoder
38class OpencodeApiClient(private val client: HttpClient) {
41class OpencodeApiClient(private val client: HttpClient) {
43+ // --- Catalog cache (providers/agents/commands) ---
44+ // These change rarely, but the ChatViewModel re-fetches them on every creation.
45+ // A short-lived in-memory cache avoids redundant network calls when navigating
46+ // between sessions on the same server.
47+ private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
48+ private val cacheMutex = Mutex()
49+ private var cachedProviders: CachedEntry<ProvidersResponse>? = null
50+ private var cachedAgents: CachedEntry<List<Agent>>? = null
51+ private var cachedCommands: CachedEntry<List<Command>>? = null
40 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
53 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
41 suspend fun ping() {
54 suspend fun ping() {
42 client.get("global/health")
55 client.get("global/health")
@@ -71,9 +84,14 @@ class OpencodeApiClient(private val client: HttpClient) {
71 text: String,
84 text: String,
72 model: ModelRef? = null,
85 model: ModelRef? = null,
73 agent: String? = null,
86 agent: String? = null,
74− ): MessageWithParts =
87+ ): MessageWithParts = withRetry {
88+ // Send an idempotency key so the server can safely deduplicate if a transient
89+ // network failure causes this request to be retried. The key is unique per call
90+ // so a genuine re-send (e.g. retry-failed) still creates a new message.
91+ val idempotencyKey = java.util.UUID.randomUUID().toString()
75 client.post("session/${encode(sessionId)}/message") {
92 client.post("session/${encode(sessionId)}/message") {
76 contentType(ContentType.Application.Json)
93 contentType(ContentType.Application.Json)
94+ header("Idempotency-Key", idempotencyKey)
78 PromptRequest(
96 PromptRequest(
79 parts = listOf(PromptPart(text = text)),
97 parts = listOf(PromptPart(text = text)),
@@ -82,6 +100,7 @@ class OpencodeApiClient(private val client: HttpClient) {
86 suspend fun abort(sessionId: String) {
105 suspend fun abort(sessionId: String) {
87 client.post("session/${encode(sessionId)}/abort")
106 client.post("session/${encode(sessionId)}/abort")
@@ -99,16 +118,47 @@ class OpencodeApiClient(private val client: HttpClient) {
99 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
118 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
102− suspend fun providers(): ProvidersResponse = withRetry {
121+ suspend fun providers(): ProvidersResponse = cacheMutex.withLock {
122+ val now = System.currentTimeMillis()
123+ val cached = cachedProviders
124+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
103− client.get("config/providers").body()
127+ val value = withRetry { client.get("config/providers").body<ProvidersResponse>() }
128+ cachedProviders = CachedEntry(value, now)
133+ suspend fun agents(): List<Agent> = cacheMutex.withLock {
134+ val now = System.currentTimeMillis()
135+ val cached = cachedAgents
136+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
139+ val value = withRetry { client.get("agent").body<List<Agent>>() }
140+ cachedAgents = CachedEntry(value, now)
106− suspend fun agents(): List<Agent> = withRetry {
145+ suspend fun commands(): List<Command> = cacheMutex.withLock {
107− client.get("agent").body()
146+ val now = System.currentTimeMillis()
147+ val cached = cachedCommands
148+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
151+ val value = withRetry { client.get("command").body<List<Command>>() }
152+ cachedCommands = CachedEntry(value, now)
110− suspend fun commands(): List<Command> = withRetry {
157+ /** Invalidate the catalog cache (e.g. on server switch). */
111− client.get("command").body()
158+ fun invalidateCache() {
159+ cachedProviders = null
161+ cachedCommands = null
114 /** Respond to a permission request so a paused tool run can proceed. */
164 /** Respond to a permission request so a paused tool run can proceed. */
@@ -176,8 +226,11 @@ internal suspend fun <T> withRetryInternal(
177 if (attempt < maxAttempts) {
227 if (attempt < maxAttempts) {
178 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
228 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
229+ // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
230+ // shorter, sometimes longer — spreads concurrent client retries and
231+ // prevents thundering-herd reconnection storms.
179− val jitter = (baseDelay * jitterFactor * Random.nextDouble()).toLong()
232+ val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
180− delay(baseDelay + jitter)
233+ delay((baseDelay + jitter).coerceAtLeast(0))