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..e6a3c9867 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 @@ -37,6 +39,14 @@ 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, + /** + * True while the agent list is being read and none is on screen yet: a local OpenCode installing + * its plugins can take tens of seconds on its first `GET /agent`, and the mode chip shows this + * instead of looking dead. + */ + val agentsLoading: Boolean = false, ) class RuntimeCatalogRepository( @@ -59,6 +69,13 @@ class RuntimeCatalogRepository( val state: StateFlow = mutableState.asStateFlow() private val refreshMutex = Mutex() + /** + * 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()) /** @@ -165,9 +182,14 @@ class RuntimeCatalogRepository( // as the fetch took — seconds, while a stopped runtime is retried — and OpenCode // models appeared under Claude Code. if (current.runtime?.id != target.id) { - RuntimeCatalogState(runtime = target, isRefreshing = true) + RuntimeCatalogState(runtime = target, isRefreshing = true, agentsLoading = true) } else { - current.copy(runtime = target, isRefreshing = true, error = null) + current.copy( + runtime = target, + isRefreshing = true, + error = null, + agentsLoading = !current.agents.hasSelectableAgent(), + ) } } @@ -234,18 +256,111 @@ class RuntimeCatalogRepository( } } } - val errors = catalog.failures(messages) - mutableState.value = + // 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() + // Read and written in one update: a background agent retry can land between this load's + // fetch and its write, and taking the list from before that would put an empty one back. + mutableState.update { current -> + val agentsToUse = + if (loadedAgents.hasSelectableAgent()) { + loadedAgents.orEmpty() + } else { + current.agentsFor(target).ifEmpty { loadedAgents.orEmpty() } + } + val agentsFound = agentsToUse.hasSelectableAgent() + // A list kept on screen - from an earlier load or a retry that already succeeded - + // carries no failure of its own, so neither the chip nor the "Agents: ..." line in + // the catalogue error reports this read's failure. + val readFailure = catalog.agentsFailure(messages) + val agentsFailure = readFailure.takeUnless { agentsFound && !loadedAgents.hasSelectableAgent() } + val errors = catalog.failures(messages).filterNot { agentsFailure == null && it == readFailure } 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"), + agentsError = agentsFailure, + // Still loading when nothing selectable came back: the retry below takes over. + agentsLoading = !agentsFound, ) + } + } + // 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 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 + * 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().hasSelectableAgent() || registry.selected.value?.id != target.id) return result + delay(AGENT_RETRY_DELAY_MILLIS) + result = runCatching { target.listAgents() } + } + return result + } + + /** + * 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 + 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) { + // Disables the "tap to retry" item straight away, so repeated taps cannot queue one + // full reconnect each behind the refresh lock before the load gets to run. + mutableState.update { if (it.runtime?.id == target.id) it.copy(agentsLoading = true) else it } + refresh() + } + return + } + 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() + } + mutableState.update { + if (it.runtime?.id == target.id && !it.agents.hasSelectableAgent()) it.copy(agentsLoading = true) else it + } + agentRetry = + target.id to + scope.launch { + val list = loadAgents(target).getOrNull()?.takeIf { it.hasSelectableAgent() } + if (registry.selected.value?.id != target.id) return@launch + mutableState.update { + when { + it.runtime?.id != target.id -> it + list != null -> it.withAgents(list) + // A load still in flight for this runtime keeps the chip loading: it reads the + // agents again itself, and a "tap to retry" now would only race it. + else -> it.copy(agentsLoading = it.isRefreshing) + } + } + } } } @@ -255,11 +370,20 @@ 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).replace(Regex("\\s*\\n\\s*"), " ")) + } + 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)) }, ) } @@ -267,6 +391,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 } } @@ -299,3 +425,17 @@ 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 + +/** 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, agentsLoading = false) +} 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 1d30bfc12..a374271f8 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 @@ -112,6 +112,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle @@ -176,6 +178,9 @@ 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 = {}, + agentsLoading: Boolean = false, selectedVariant: String? = null, onSelectVariant: (String?) -> Unit = {}, attachments: List = emptyList(), @@ -644,6 +649,8 @@ fun ChatHomeScreen( agents = agents, selectedAgentId = selectedAgentId, onSelectAgent = onSelectAgent, + onRefreshAgents = onRefreshAgents, + agentsLoading = agentsLoading, thinkingOptions = providers .firstOrNull { it.id == selectedProviderId } @@ -1238,6 +1245,8 @@ private fun ChatComposer( agents: List, selectedAgentId: String?, onSelectAgent: (String) -> Unit, + onRefreshAgents: () -> Unit, + agentsLoading: Boolean = false, thinkingOptions: List, selectedVariant: String?, onSelectVariant: (String?) -> Unit, @@ -1601,6 +1610,8 @@ private fun ChatComposer( agents = agents, selectedAgentId = selectedAgentId, onSelect = onSelectAgent, + onRefresh = onRefreshAgents, + loading = agentsLoading, ) // Auto-accept answers permission prompts, so it only means something on a runtime // that asks for permission. Antigravity decides permissions per invocation from its @@ -1869,6 +1880,8 @@ private fun ModeChip( agents: List, selectedAgentId: String?, onSelect: (String) -> Unit, + onRefresh: () -> Unit = {}, + loading: Boolean = false, ) { var expanded by remember { mutableStateOf(false) } // Every primary agent the server reports is offered, not just build/plan: plugins such as @@ -1889,7 +1902,13 @@ private fun ModeChip( modifier = Modifier .widthIn(max = 92.dp) - .clickable(onClick = { expanded = true }), + .clickable( + onClick = { + expanded = true + if (selectableAgents.isEmpty() && !loading) onRefresh() + }, + ) + .testTag("chat-mode-chip"), shape = RoundedCornerShape(100.dp), color = MaterialTheme.colorScheme.surfaceVariant, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, @@ -1899,11 +1918,20 @@ private fun ModeChip( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Icon( - Icons.Outlined.Shield, - contentDescription = stringResource(R.string.cd_agent_mode), - modifier = Modifier.size(14.dp), - ) + if (loading && selectableAgents.isEmpty()) { + val loadingDescription = stringResource(R.string.chat_agents_loading) + CircularProgressIndicator( + modifier = Modifier.size(14.dp).semantics { contentDescription = loadingDescription }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + strokeWidth = 1.5.dp, + ) + } else { + Icon( + Icons.Outlined.Shield, + contentDescription = stringResource(R.string.cd_agent_mode), + modifier = Modifier.size(14.dp), + ) + } Text( label, style = MaterialTheme.typography.labelMedium, @@ -1918,6 +1946,20 @@ private fun ModeChip( } } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (selectableAgents.isEmpty()) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (loading) R.string.chat_agents_loading else R.string.chat_agents_load_failed, + ), + ) + }, + onClick = onRefresh, + enabled = !loading, + modifier = if (loading) Modifier.testTag("chat-mode-loading") else Modifier, + ) + } selectableAgents.forEach { agent -> DropdownMenuItem( text = { Text(agent.name) }, diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt index e6c43af34..22623a018 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/settings/SettingsViewModel.kt @@ -51,6 +51,8 @@ data class SettingsUiState( val availableProviders: List = emptyList(), val connectedProviderIds: Set = emptySet(), val agents: List = emptyList(), + /** The selected runtime is still reading its agents; see [RuntimeCatalogState.agentsLoading]. */ + val agentsLoading: Boolean = false, val providerId: String? = null, val modelId: String? = null, val agentId: String? = null, @@ -217,6 +219,7 @@ class SettingsViewModel( availableProviders = managed.all, connectedProviderIds = (managed.connected.toSet() + oauth.locallyConnected) - oauth.locallyDisconnected, agents = core.runtime.agents.selectablePrimaryAgents(), + agentsLoading = core.runtime.agentsLoading, providerId = core.preferences.providerId, modelId = core.preferences.modelId, agentId = core.preferences.agentId, 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 ba6e60f34..648da9b05 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt @@ -1062,6 +1062,7 @@ fun AndCodeApp( state = chatState, providers = settingsState.providers, agents = settingsState.agents, + agentsLoading = settingsState.agentsLoading, selectedProviderId = chatState.selectedProviderId ?: fallbackProviderId, selectedModelId = chatState.selectedModelId ?: fallbackModelId, selectedAgentId = chatState.selectedAgentId ?: settingsState.agentId, @@ -1129,6 +1130,7 @@ fun AndCodeApp( }, onSelectModel = settingsViewModel::selectModel, onSelectAgent = settingsViewModel::selectAgent, + onRefreshAgents = app.catalogRepository::refreshAgents, selectedVariant = chatState.selectedVariant, onSelectVariant = chatViewModel::selectVariant, onAttach = { attachmentLauncher.launch("*/*") }, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index b4a06946e..56c0e0014 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -484,6 +484,8 @@ الموجّه بناء تخطيط + جارٍ تحميل الوكلاء… + تعذّر تحميل الوكلاء. انقر لإعادة المحاولة. قبول تلقائي تلقائي %1$s tok/s diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c9002a930..ce776e667 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -478,6 +478,8 @@ Prompt Construir Planificar + Cargando agentes… + No se pudieron cargar los agentes. Toca para reintentar. Aceptación automática Auto %1$s tok/s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7794e2f48..8f9d5c997 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -478,6 +478,8 @@ Invite Construire Planifier + Chargement des agents… + Impossible de charger les agents. Touchez pour réessayer. Acceptation automatique Auto %1$s tok/s diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index fc319ecdb..7abc9ca82 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -476,6 +476,8 @@ プロンプト ビルド 計画 + エージェントを読み込み中… + エージェントを読み込めませんでした。タップして再試行。 自動承認 自動 %1$s tok/s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 567c4d4e5..0e91f4dd7 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -478,6 +478,8 @@ Prompt Construir Planejar + Carregando agentes… + Não foi possível carregar os agentes. Toque para tentar novamente. Aceitação automática Auto %1$s tok/s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c54572b78..409c31c93 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -480,6 +480,8 @@ Промпт Сборка План + Загрузка агентов… + Не удалось загрузить агентов. Нажмите, чтобы повторить. Автоприём Авто %1$s tok/s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 47bc074cb..cc7bb8af1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -474,6 +474,8 @@ 提示词 构建 计划 + 正在加载智能体… + 无法加载智能体,点按重试。 自动接受 自动 %1$s tok/s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3ca369747..ff4496086 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -464,6 +464,8 @@ Prompt Build Plan + Loading agents… + Couldn\'t load agents. Tap to retry. Auto accept Auto %1$s tok/s 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..4247b1cab 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,167 @@ 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 (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( + store = FakeStore(selectedRuntimeId = "mac"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + val repository = RuntimeCatalogRepository(registry = registry, scope = TestScope(dispatcher)) + + 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) + 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 + fun `refreshing agents fills an empty picker without touching the rest of the catalogue`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + // 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"), + localTarget = FakeTarget("local-android", RuntimeType.LOCAL), + remoteFactory = { target }, + ) + 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() + advanceUntilIdle() + + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + 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 }) + // The kept list is not this read's: no agents failure on the chip or in the catalogue error. + assertNull(repository.state.value.agentsError) + assertNull(repository.state.value.error) + } + + @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) + } + + @Test + fun `agents read as loading until they arrive, and stop loading once retries give up`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget("mac") + // The load's read and the whole background retry (1 + 5 reads) fail. + 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)) + + // The load's own read has failed and published; the background retry is still to come, + // so the chip has nothing to pick yet and reads as loading. + testScheduler.runCurrent() + assertTrue(repository.state.value.agentsLoading) + + advanceUntilIdle() + assertTrue(repository.state.value.agents.isEmpty()) + assertFalse(repository.state.value.agentsLoading) + + // A tap on the empty chip marks it loading straight away, before the retry has run. + repository.refreshAgents() + assertTrue(repository.state.value.agentsLoading) + advanceUntilIdle() + assertEquals(listOf("build"), repository.state.value.agents.map { it.name }) + assertFalse(repository.state.value.agentsLoading) + } + private class FakeStore( profiles: List = listOf(profileStatic("mac")), override var selectedRuntimeId: String? = null, @@ -375,7 +536,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(