From 06dbac019120a232df52fa41f2cd1fdbf6e5ceeb Mon Sep 17 00:00:00 2001 From: lcy Date: Sat, 17 Oct 2026 17:52:32 +0800 Subject: [PATCH 1/4] fix(chat): retry agents that fail to load instead of a dead mode chip A local OpenCode with plugins (oh-my-opencode-slim, opencode-mem, ...) installs them on its first GET /agent after a start: on a fresh cache that call took ~25s here, and it can fail outright. The catalogue took that one answer as final, so the agent list stayed empty until something else refreshed it, and the mode chip kept showing "build" over a menu with nothing in it - tapping it did nothing visible. Reproduced on an emulator against a proxy that fails the first /agent: the chip stayed dead for the whole session, across 60s and a background/foreground round trip. - RuntimeCatalogRepository retries GET /agent (4 x 3s) while the answer is a failure or an empty list - refreshAgents() re-reads only the agents, keeping the rest of the catalogue - the mode chip asks for a refresh when tapped with no agents, instead of opening an empty menu --- .../repository/RuntimeCatalogRepository.kt | 36 +++++++++++- .../andcode/feature/chat/ChatHomeScreen.kt | 17 +++++- .../yugahashimoto/andcode/ui/AndCodeApp.kt | 1 + .../RuntimeCatalogRepositoryTest.kt | 56 ++++++++++++++++++- 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt index 1e13080be..a048ba84f 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt @@ -200,7 +200,7 @@ class RuntimeCatalogRepository( supervisorScope { val sessions = async { runCatching { target.listSessions() } } val providers = async { runCatching { target.listProviders() } } - val agents = async { runCatching { target.listAgents() } } + val agents = async { loadAgents(target) } val workspaces = async { runCatching { target.listWorkspaces() } } LoadedCatalog( sessions = sessions.await(), @@ -249,6 +249,38 @@ class RuntimeCatalogRepository( } } + /** + * The runtime's agents, retried a few times when the first answer is a failure or an empty list. + * + * A local OpenCode with plugins (oh-my-opencode-slim, opencode-mem, ...) installs them on its + * first `GET /agent` after a start, which can take tens of seconds or fail outright. That one + * answer used to be final until something else refreshed the catalogue, so the agent chip kept + * showing "build" over an empty menu that nothing would ever refill. + */ + private suspend fun loadAgents(target: RuntimeTarget): Result> { + var result = runCatching { target.listAgents() } + repeat(AGENT_RETRIES) { + if (result.getOrNull()?.isNotEmpty() == true || registry.selected.value?.id != target.id) return result + delay(AGENT_RETRY_DELAY_MILLIS) + result = runCatching { target.listAgents() } + } + return result + } + + /** + * Re-reads only the agent list, for a picker the user opened while it was empty. Leaves the rest + * of the catalogue alone and keeps any agents already on screen if this read fails too. + */ + fun refreshAgents() { + val target = registry.selected.value ?: return + scope.launch { + val agents = loadAgents(target) + if (registry.selected.value?.id != target.id) return@launch + val list = agents.getOrNull()?.takeIf { it.isNotEmpty() } ?: return@launch + mutableState.update { if (it.runtime?.id == target.id) it.copy(agents = list) else it } + } + } + private data class LoadedCatalog( val sessions: Result>, val providers: Result, @@ -267,6 +299,8 @@ class RuntimeCatalogRepository( private companion object { const val CONNECT_RETRIES = 6 const val CONNECT_RETRY_DELAY_MILLIS = 2_500L + const val AGENT_RETRIES = 4 + const val AGENT_RETRY_DELAY_MILLIS = 3_000L } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt index 0dc7f7a23..88efe4bab 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatHomeScreen.kt @@ -181,6 +181,8 @@ fun ChatHomeScreen( onSelectRuntime: (String) -> Unit, onSelectModel: (String, String) -> Unit, onSelectAgent: (String) -> Unit, + /** Asks the runtime for its agents again; the mode chip calls it when its list is empty. */ + onRefreshAgents: () -> Unit = {}, selectedVariant: String? = null, onSelectVariant: (String?) -> Unit = {}, /** @@ -660,6 +662,7 @@ fun ChatHomeScreen( agents = agents, selectedAgentId = selectedAgentId, onSelectAgent = onSelectAgent, + onRefreshAgents = onRefreshAgents, thinkingOptions = providers .firstOrNull { it.id == selectedProviderId } @@ -1293,6 +1296,7 @@ private fun ChatComposer( agents: List, selectedAgentId: String?, onSelectAgent: (String) -> Unit, + onRefreshAgents: () -> Unit, thinkingOptions: List, selectedVariant: String?, onSelectVariant: (String?) -> Unit, @@ -1660,6 +1664,7 @@ private fun ChatComposer( agents = agents, selectedAgentId = selectedAgentId, onSelect = onSelectAgent, + onRefresh = onRefreshAgents, ) // Auto-accept answers permission prompts, so it only means something on a runtime // that asks for permission. Antigravity decides permissions per invocation from its @@ -2003,6 +2008,7 @@ private fun ModeChip( agents: List, selectedAgentId: String?, onSelect: (String) -> Unit, + onRefresh: () -> Unit = {}, ) { var expanded by remember { mutableStateOf(false) } // Every primary agent the server reports is offered, not just build/plan: plugins such as @@ -2023,7 +2029,16 @@ private fun ModeChip( modifier = Modifier .widthIn(max = 92.dp) - .clickable(onClick = { expanded = true }), + .clickable( + onClick = { + // An empty list is a runtime that has not answered `GET /agent` yet (a + // local server still installing its plugins) or whose answer failed. + // Opening the menu then showed nothing, so the tap read as a dead + // button; ask again instead, and the menu has items next time. + if (selectableAgents.isEmpty()) onRefresh() else expanded = true + }, + ) + .testTag("chat-mode-chip"), shape = RoundedCornerShape(100.dp), color = MaterialTheme.colorScheme.surfaceVariant, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt index e9dfa449b..cdb082e72 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt @@ -1145,6 +1145,7 @@ fun AndCodeApp( }, onSelectModel = settingsViewModel::selectModel, onSelectAgent = settingsViewModel::selectAgent, + onRefreshAgents = app.catalogRepository::refreshAgents, selectedVariant = chatState.selectedVariant, onSelectVariant = chatViewModel::selectVariant, onToggleFastMode = { enabled -> chatViewModel.setFastMode(enabled) }, diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt index d157a8895..ce96cfacb 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt @@ -271,6 +271,53 @@ class RuntimeCatalogRepositoryTest { connected = emptyList(), ) + @Test + fun `agents that fail on the first read are retried until the runtime answers`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + // A local OpenCode installing its plugins: the first GET /agent fails, the next is empty. + target.agentAnswers.add { error("plugin load failed") } + target.agentAnswers.add { emptyList() } + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + + advanceUntilIdle() + + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + assertEquals(3, target.agentCalls) + assertNull(repository.state.value.error) + } + + @Test + fun `refreshing agents fills an empty picker without touching the rest of the catalogue`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + repeat(5) { target.agentAnswers.add { error("plugin load failed") } } + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + advanceUntilIdle() + assertTrue(repository.state.value.agents.isEmpty()) + val sessionsBefore = repository.state.value.sessions + + repository.refreshAgents() + advanceUntilIdle() + + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + assertEquals(sessionsBefore, repository.state.value.sessions) + } + private class FakeStore( profiles: List = listOf(profileStatic("mac")), override var selectedRuntimeId: String? = null, @@ -375,7 +422,14 @@ class RuntimeCatalogRepositoryTest { ) } - override suspend fun listAgents(): List = listOf(OpenCodeAgent("build", mode = "primary")) + /** Answers for successive [listAgents] calls; once used up, the default single `build`. */ + val agentAnswers = ArrayDeque<() -> List>() + var agentCalls = 0 + + override suspend fun listAgents(): List { + agentCalls++ + return agentAnswers.removeFirstOrNull()?.invoke() ?: listOf(OpenCodeAgent("build", mode = "primary")) + } override suspend fun listWorkspaces(): List = listOf( From 4d680ddc31a2588374770f18ada649062e1b76e6 Mon Sep 17 00:00:00 2001 From: lcy Date: Sat, 17 Oct 2026 18:06:33 +0800 Subject: [PATCH 2/4] fix(chat): retry agents outside the catalogue lock, keep a shown list Per review: the agent retry ran inside load()'s refresh lock, holding the whole catalogue (and every other refresh) behind up to several slow GET /agent calls. load() now reads agents once and publishes, then a single background retry (agentRetryJob) runs outside the lock. - a failed or empty agent read no longer blanks agents this runtime already showed (same rule as providers, #373) - repeated taps on an empty chip share one retry instead of stacking polls - a runtime that never connected gets a full refresh() from the chip - success means at least one selectable primary agent, matching the chip --- .../repository/RuntimeCatalogRepository.kt | 55 ++++++++++++++---- .../RuntimeCatalogRepositoryTest.kt | 56 ++++++++++++++++++- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt index a048ba84f..7e5eb4b42 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt @@ -4,11 +4,13 @@ import com.yugahashimoto.andcode.core.api.OpenCodeAgent import com.yugahashimoto.andcode.core.api.OpenCodeHealth import com.yugahashimoto.andcode.core.api.OpenCodeSession import com.yugahashimoto.andcode.core.api.ProviderCatalog +import com.yugahashimoto.andcode.core.api.selectablePrimaryAgents import com.yugahashimoto.andcode.runtime.LocalAgent import com.yugahashimoto.andcode.runtime.RuntimeRegistry import com.yugahashimoto.andcode.runtime.RuntimeTarget import com.yugahashimoto.andcode.runtime.WorkspaceRef import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -59,6 +61,9 @@ class RuntimeCatalogRepository( val state: StateFlow = mutableState.asStateFlow() private val refreshMutex = Mutex() + /** The one agent retry in flight, so repeated taps on an empty mode chip do not stack polls. */ + @Volatile private var agentRetryJob: Job? = null + private val mutableAllSessions = MutableStateFlow>(emptyList()) /** @@ -200,7 +205,7 @@ class RuntimeCatalogRepository( supervisorScope { val sessions = async { runCatching { target.listSessions() } } val providers = async { runCatching { target.listProviders() } } - val agents = async { loadAgents(target) } + val agents = async { runCatching { target.listAgents() } } val workspaces = async { runCatching { target.listWorkspaces() } } LoadedCatalog( sessions = sessions.await(), @@ -235,22 +240,34 @@ class RuntimeCatalogRepository( } } val errors = catalog.failures(messages) + // Like the providers above: a read that failed or offered no agent to pick must not blank + // a list this runtime already showed, or a flaky `GET /agent` empties a working picker. + val loadedAgents = catalog.agents.getOrNull() + val agentsToUse = + if (loadedAgents.hasSelectableAgent()) { + loadedAgents.orEmpty() + } else { + mutableState.value.agentsFor(target).ifEmpty { loadedAgents.orEmpty() } + } mutableState.value = RuntimeCatalogState( runtime = target, health = connection.getOrNull(), sessions = catalog.sessions.getOrDefault(emptyList()), providers = providersToUse, - agents = catalog.agents.getOrDefault(emptyList()), + agents = agentsToUse, workspaces = catalog.workspaces.getOrDefault(emptyList()), isRefreshing = false, error = errors.takeIf { it.isNotEmpty() }?.joinToString("\n"), ) } + // Outside the lock: a local OpenCode installing its plugins can take tens of seconds per + // `GET /agent`, and the rest of the catalogue - and every other refresh - must not wait on it. + if (!mutableState.value.agentsFor(target).hasSelectableAgent()) refreshAgents() } /** - * The runtime's agents, retried a few times when the first answer is a failure or an empty list. + * The runtime's agents, retried a few times while the answer fails or offers nothing to pick. * * A local OpenCode with plugins (oh-my-opencode-slim, opencode-mem, ...) installs them on its * first `GET /agent` after a start, which can take tens of seconds or fail outright. That one @@ -260,7 +277,7 @@ class RuntimeCatalogRepository( private suspend fun loadAgents(target: RuntimeTarget): Result> { var result = runCatching { target.listAgents() } repeat(AGENT_RETRIES) { - if (result.getOrNull()?.isNotEmpty() == true || registry.selected.value?.id != target.id) return result + if (result.getOrNull().hasSelectableAgent() || registry.selected.value?.id != target.id) return result delay(AGENT_RETRY_DELAY_MILLIS) result = runCatching { target.listAgents() } } @@ -268,17 +285,26 @@ class RuntimeCatalogRepository( } /** - * Re-reads only the agent list, for a picker the user opened while it was empty. Leaves the rest - * of the catalogue alone and keeps any agents already on screen if this read fails too. + * Re-reads the agent list in the background until the runtime offers one to pick, for a load + * whose `GET /agent` failed or for a mode chip tapped while empty. + * + * Only one retry runs at a time. A runtime that never connected gets a full [refresh] instead, + * so the chip does not fill in agents over an otherwise empty, errored catalogue. The rest of the + * catalogue is left alone, and a failed retry keeps whatever agents are already on screen. */ fun refreshAgents() { val target = registry.selected.value ?: return - scope.launch { - val agents = loadAgents(target) - if (registry.selected.value?.id != target.id) return@launch - val list = agents.getOrNull()?.takeIf { it.isNotEmpty() } ?: return@launch - mutableState.update { if (it.runtime?.id == target.id) it.copy(agents = list) else it } + if (mutableState.value.runtime?.id == target.id && mutableState.value.health == null && !mutableState.value.isRefreshing) { + refresh() + return } + if (agentRetryJob?.isActive == true) return + agentRetryJob = + scope.launch { + val list = loadAgents(target).getOrNull()?.takeIf { it.hasSelectableAgent() } ?: return@launch + if (registry.selected.value?.id != target.id) return@launch + mutableState.update { if (it.runtime?.id == target.id) it.copy(agents = list) else it } + } } private data class LoadedCatalog( @@ -333,3 +359,10 @@ internal fun shouldReplaceProviderCatalog( current: ProviderCatalog, fresh: ProviderCatalog, ): Boolean = fresh.connected.isNotEmpty() || current.connected.isEmpty() + +/** The agents on screen, but only while the state still belongs to [target]; see [providersFor]. */ +private fun RuntimeCatalogState.agentsFor(target: RuntimeTarget): List = + if (runtime?.id == target.id) agents else emptyList() + +/** Whether this answer offers at least one agent the mode chip can show. */ +private fun List?.hasSelectableAgent(): Boolean = this?.selectablePrimaryAgents()?.isNotEmpty() == true diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt index ce96cfacb..9251f7d30 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt @@ -289,9 +289,10 @@ class RuntimeCatalogRepositoryTest { advanceUntilIdle() + // The load publishes after one read; the retry runs after it, outside the refresh lock. assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) assertEquals(3, target.agentCalls) - assertNull(repository.state.value.error) + assertFalse(repository.state.value.isRefreshing) } @Test @@ -299,7 +300,8 @@ class RuntimeCatalogRepositoryTest { runTest { val dispatcher = StandardTestDispatcher(testScheduler) val target = FakeTarget("mac") - repeat(5) { target.agentAnswers.add { error("plugin load failed") } } + // The load's own read plus its background retry (1 + 5 reads) all fail. + repeat(6) { target.agentAnswers.add { error("plugin load failed") } } val registry = RuntimeRegistry( store = FakeStore(selectedRuntimeId = "mac"), @@ -309,6 +311,7 @@ class RuntimeCatalogRepositoryTest { val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) advanceUntilIdle() assertTrue(repository.state.value.agents.isEmpty()) + assertEquals(6, target.agentCalls) val sessionsBefore = repository.state.value.sessions repository.refreshAgents() @@ -318,6 +321,55 @@ class RuntimeCatalogRepositoryTest { assertEquals(sessionsBefore, repository.state.value.sessions) } + @Test + fun `a refresh whose agent read fails keeps the agents already on screen`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + advanceUntilIdle() + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + + // Every read of the next refresh fails, retries included. + repeat(10) { target.agentAnswers.add { error("plugin load failed") } } + repository.refresh() + advanceUntilIdle() + + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + } + + @Test + fun `repeated agent refreshes run a single retry at a time`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + // The load and its background retry give up empty-handed (1 + 5 failed reads). + repeat(6) { target.agentAnswers.add { error("plugin load failed") } } + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + advanceUntilIdle() + val callsBefore = target.agentCalls + + assertTrue(repository.state.value.agents.isEmpty()) + + repeat(5) { repository.refreshAgents() } + advanceUntilIdle() + + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + assertEquals(callsBefore + 1, target.agentCalls) + } + private class FakeStore( profiles: List = listOf(profileStatic("mac")), override var selectedRuntimeId: String? = null, From 69a1d3b621a2745d3831383580ffae4439a2aacd Mon Sep 17 00:00:00 2001 From: lcy Date: Sat, 17 Oct 2026 18:12:53 +0800 Subject: [PATCH 3/4] fix(chat): clear the agents error once the retry succeeds Per review: a first GET /agent failure put "Agents: ..." into the catalogue error, and the background retry only replaced the list, so the workspaces screen kept showing the failure after the agents had arrived. The agents line is kept separately and dropped when the retry fills the list. Also: a retry left over from a runtime the user switched away from is cancelled rather than blocking the new runtime's, and starting a retry is synchronized since taps (main) and load (IO) both reach it. --- .../repository/RuntimeCatalogRepository.kt | 44 +++++++++++++++---- .../RuntimeCatalogRepositoryTest.kt | 26 +++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt index 7e5eb4b42..4ac51e0a8 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt @@ -39,6 +39,8 @@ data class RuntimeCatalogState( val workspaces: List = emptyList(), val isRefreshing: Boolean = false, val error: String? = null, + /** The line of [error] that is about agents, so a later successful agent read can drop it. */ + val agentsError: String? = null, ) class RuntimeCatalogRepository( @@ -61,8 +63,12 @@ class RuntimeCatalogRepository( val state: StateFlow = mutableState.asStateFlow() private val refreshMutex = Mutex() - /** The one agent retry in flight, so repeated taps on an empty mode chip do not stack polls. */ - @Volatile private var agentRetryJob: Job? = null + /** + * The one agent retry in flight and the runtime it is for, so repeated taps on an empty mode + * chip do not stack polls. Guarded by [agentRetryLock]: taps (main) and load (IO) both start it. + */ + private var agentRetry: Pair? = null + private val agentRetryLock = Any() private val mutableAllSessions = MutableStateFlow>(emptyList()) @@ -259,6 +265,7 @@ class RuntimeCatalogRepository( workspaces = catalog.workspaces.getOrDefault(emptyList()), isRefreshing = false, error = errors.takeIf { it.isNotEmpty() }?.joinToString("\n"), + agentsError = catalog.agentsFailure(messages), ) } // Outside the lock: a local OpenCode installing its plugins can take tens of seconds per @@ -298,13 +305,22 @@ class RuntimeCatalogRepository( refresh() return } - if (agentRetryJob?.isActive == true) return - agentRetryJob = - scope.launch { - val list = loadAgents(target).getOrNull()?.takeIf { it.hasSelectableAgent() } ?: return@launch - if (registry.selected.value?.id != target.id) return@launch - mutableState.update { if (it.runtime?.id == target.id) it.copy(agents = list) else it } + synchronized(agentRetryLock) { + val running = agentRetry + if (running != null && running.second.isActive) { + // Still polling this runtime: let it finish. A retry left over from the runtime the + // user switched away from would only give up, so it must not block this one's. + if (running.first == target.id) return + running.second.cancel() } + agentRetry = + target.id to + scope.launch { + val list = loadAgents(target).getOrNull()?.takeIf { it.hasSelectableAgent() } ?: return@launch + if (registry.selected.value?.id != target.id) return@launch + mutableState.update { if (it.runtime?.id == target.id) it.withAgents(list) else it } + } + } } private data class LoadedCatalog( @@ -313,11 +329,14 @@ class RuntimeCatalogRepository( val agents: Result>, val workspaces: Result>, ) { + fun agentsFailure(messages: RuntimeCatalogMessages): String? = + agents.exceptionOrNull()?.let { messages.agents(it.safeMessage(messages.connectionFailed)) } + fun failures(messages: RuntimeCatalogMessages): List = listOfNotNull( sessions.exceptionOrNull()?.let { messages.sessions(it.safeMessage(messages.connectionFailed)) }, providers.exceptionOrNull()?.let { messages.providers(it.safeMessage(messages.connectionFailed)) }, - agents.exceptionOrNull()?.let { messages.agents(it.safeMessage(messages.connectionFailed)) }, + agentsFailure(messages), workspaces.exceptionOrNull()?.let { messages.workspaces(it.safeMessage(messages.connectionFailed)) }, ) } @@ -366,3 +385,10 @@ private fun RuntimeCatalogState.agentsFor(target: RuntimeTarget): List?.hasSelectableAgent(): Boolean = this?.selectablePrimaryAgents()?.isNotEmpty() == true + +/** This state with [agents] in place, and the earlier agents failure removed from [error]. */ +private fun RuntimeCatalogState.withAgents(agents: List): RuntimeCatalogState { + val remaining = + error?.lineSequence()?.filterNot { it == agentsError }?.joinToString("\n")?.takeIf { it.isNotBlank() } + return copy(agents = agents, error = remaining, agentsError = null) +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt index 9251f7d30..06103ebd0 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt @@ -293,6 +293,32 @@ class RuntimeCatalogRepositoryTest { assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) assertEquals(3, target.agentCalls) assertFalse(repository.state.value.isRefreshing) + // The load's "Agents: ..." failure no longer applies once the retry got them. + assertNull(repository.state.value.error) + } + + @Test + fun `an agent retry for a runtime that never connected refreshes the whole catalogue`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac", connectError = IllegalStateException("offline")) + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + advanceUntilIdle() + assertNull(repository.state.value.health) + val callsBefore = target.agentCalls + + repository.refreshAgents() + advanceUntilIdle() + + // A full refresh retried the connection instead of asking a dead server for agents. + assertEquals(callsBefore, target.agentCalls) + assertEquals("offline", repository.state.value.error) } @Test From 8a6b76672f3843721d6c693c7b878356e8ae7921 Mon Sep 17 00:00:00 2001 From: lcy Date: Sat, 17 Oct 2026 18:15:46 +0800 Subject: [PATCH 4/4] fix(chat): keep the agents error one line, leave early retries to load - decoding errors carry the JSON input on a second line, so the agents failure never matched a line of the joined error and stayed on screen after a successful retry; it is flattened to one line when recorded - a chip tap while the runtime is still connecting no longer starts its own agent poll ahead of the load, which retries the agents once connected --- .../data/repository/RuntimeCatalogRepository.kt | 15 ++++++++++++--- .../repository/RuntimeCatalogRepositoryTest.kt | 5 +++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt index 4ac51e0a8..aa5327e04 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepository.kt @@ -301,8 +301,11 @@ class RuntimeCatalogRepository( */ fun refreshAgents() { val target = registry.selected.value ?: return - if (mutableState.value.runtime?.id == target.id && mutableState.value.health == null && !mutableState.value.isRefreshing) { - refresh() + val current = mutableState.value + if (current.runtime?.id == target.id && current.health == null) { + // Not connected yet. A load still in flight will retry the agents itself once it + // connects; a load that gave up gets a full refresh, not a poll of a dead server. + if (!current.isRefreshing) refresh() return } synchronized(agentRetryLock) { @@ -329,8 +332,14 @@ class RuntimeCatalogRepository( val agents: Result>, val workspaces: Result>, ) { + /** + * One line, so [withAgents] can find it again in [RuntimeCatalogState.error]: decoding errors + * carry the JSON input on a second line, which a line-by-line removal would never match. + */ fun agentsFailure(messages: RuntimeCatalogMessages): String? = - agents.exceptionOrNull()?.let { messages.agents(it.safeMessage(messages.connectionFailed)) } + agents.exceptionOrNull()?.let { + messages.agents(it.safeMessage(messages.connectionFailed).replace(Regex("\\s*\\n\\s*"), " ")) + } fun failures(messages: RuntimeCatalogMessages): List = listOfNotNull( diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt index 06103ebd0..65a5036aa 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt @@ -276,8 +276,9 @@ class RuntimeCatalogRepositoryTest { runTest { val dispatcher = StandardTestDispatcher(testScheduler) val target = FakeTarget("mac") - // A local OpenCode installing its plugins: the first GET /agent fails, the next is empty. - target.agentAnswers.add { error("plugin load failed") } + // A local OpenCode installing its plugins: the first GET /agent fails (with a two-line + // message, as a decoding error has), the next is empty. + target.agentAnswers.add { error("Unexpected JSON token\nJSON input: [{\"hidden\":null}]") } target.agentAnswers.add { emptyList() } val registry = RuntimeRegistry(