@@ -21,6 +21,7 @@ import io.ktor.client.call.body
2121import io.ktor.client.plugins.ClientRequestException
2222import io.ktor.client.request.delete
2323import io.ktor.client.request.get
24+import io.ktor.client.request.header
2425import io.ktor.client.request.parameter
2526import io.ktor.client.request.patch
2627import io.ktor.client.request.post
@@ -28,6 +29,8 @@ import io.ktor.client.request.setBody
2829import io.ktor.http.ContentType
2930import io.ktor.http.contentType
3031import kotlinx.coroutines.delay
32+import kotlinx.coroutines.sync.Mutex
33+import kotlinx.coroutines.sync.withLock
3134import kotlin.random.Random
3235import java.net.URLEncoder
3336
@@ -37,6 +40,16 @@ import java.net.URLEncoder
3740 */
3841class OpencodeApiClient(private val client: HttpClient) {
3942
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
52+
4053 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
4154 suspend fun ping() {
4255 client.get("global/health")
@@ -71,9 +84,14 @@ class OpencodeApiClient(private val client: HttpClient) {
7184 text: String,
7285 model: ModelRef? = null,
7386 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()
7592 client.post("session/${encode(sessionId)}/message") {
7693 contentType(ContentType.Application.Json)
94+ header("Idempotency-Key", idempotencyKey)
7795 setBody(
7896 PromptRequest(
7997 parts = listOf(PromptPart(text = text)),
@@ -82,6 +100,7 @@ class OpencodeApiClient(private val client: HttpClient) {
82100 ),
83101 )
84102 }.body()
103+ }
85104
86105 suspend fun abort(sessionId: String) {
87106 client.post("session/${encode(sessionId)}/abort")
@@ -99,16 +118,47 @@ class OpencodeApiClient(private val client: HttpClient) {
99118 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
100119 }.body()
101120
102− suspend fun providers(): ProvidersResponse = withRetry {
103− client.get("config/providers").body()
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) {
125+ cached.value
126+ } else {
127+ val value = withRetry { client.get("config/providers").body<ProvidersResponse>() }
128+ cachedProviders = CachedEntry(value, now)
129+ value
130+ }
131+ }
132+
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) {
137+ cached.value
138+ } else {
139+ val value = withRetry { client.get("agent").body<List<Agent>>() }
140+ cachedAgents = CachedEntry(value, now)
141+ value
142+ }
104143 }
105144
106− suspend fun agents(): List<Agent> = withRetry {
107− client.get("agent").body()
145+ suspend fun commands(): List<Command> = cacheMutex.withLock {
146+ val now = System.currentTimeMillis()
147+ val cached = cachedCommands
148+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
149+ cached.value
150+ } else {
151+ val value = withRetry { client.get("command").body<List<Command>>() }
152+ cachedCommands = CachedEntry(value, now)
153+ value
154+ }
108155 }
109156
110− suspend fun commands(): List<Command> = withRetry {
111− client.get("command").body()
157+ /** Invalidate the catalog cache (e.g. on server switch). */
158+ fun invalidateCache() {
159+ cachedProviders = null
160+ cachedAgents = null
161+ cachedCommands = null
112162 }
113163
114164 /** Respond to a permission request so a paused tool run can proceed. */
@@ -176,8 +226,11 @@ internal suspend fun <T> withRetryInternal(
176226 }
177227 if (attempt < maxAttempts) {
178228 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
179− val jitter = (baseDelay * jitterFactor * Random.nextDouble()).toLong()
180− delay(baseDelay + jitter)
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.
232+ val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
233+ delay((baseDelay + jitter).coerceAtLeast(0))
181234 }
182235 }
183236 }