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..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 @@ -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,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( @@ -59,6 +63,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()) /** @@ -235,18 +246,84 @@ 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"), + agentsError = catalog.agentsFailure(messages), ) } + // 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) 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() + } + 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( @@ -255,11 +332,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 +353,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 +387,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) +} 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..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 @@ -271,6 +271,132 @@ 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 }) + } + + @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, @@ -375,7 +501,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(