Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,6 +39,8 @@ data class RuntimeCatalogState(
val workspaces: List<WorkspaceRef> = 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(
Expand All @@ -59,6 +63,13 @@ class RuntimeCatalogRepository(
val state: StateFlow<RuntimeCatalogState> = 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<String, Job>? = null
private val agentRetryLock = Any()

private val mutableAllSessions = MutableStateFlow<List<RuntimeSessionRef>>(emptyList())

/**
Expand Down Expand Up @@ -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<List<OpenCodeAgent>> {
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(
Expand All @@ -255,18 +332,29 @@ class RuntimeCatalogRepository(
val agents: Result<List<OpenCodeAgent>>,
val workspaces: Result<List<WorkspaceRef>>,
) {
/**
* 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<String> =
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)) },
)
}

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
}
}

Expand Down Expand Up @@ -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<OpenCodeAgent> =
if (runtime?.id == target.id) agents else emptyList()

/** Whether this answer offers at least one agent the mode chip can show. */
private fun List<OpenCodeAgent>?.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<OpenCodeAgent>): RuntimeCatalogState {
val remaining =
error?.lineSequence()?.filterNot { it == agentsError }?.joinToString("\n")?.takeIf { it.isNotBlank() }
return copy(agents = agents, error = remaining, agentsError = null)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
/**
Expand Down Expand Up @@ -660,6 +662,7 @@ fun ChatHomeScreen(
agents = agents,
selectedAgentId = selectedAgentId,
onSelectAgent = onSelectAgent,
onRefreshAgents = onRefreshAgents,
thinkingOptions =
providers
.firstOrNull { it.id == selectedProviderId }
Expand Down Expand Up @@ -1293,6 +1296,7 @@ private fun ChatComposer(
agents: List<OpenCodeAgent>,
selectedAgentId: String?,
onSelectAgent: (String) -> Unit,
onRefreshAgents: () -> Unit,
thinkingOptions: List<String>,
selectedVariant: String?,
onSelectVariant: (String?) -> Unit,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2003,6 +2008,7 @@ private fun ModeChip(
agents: List<OpenCodeAgent>,
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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConnectionProfile> = listOf(profileStatic("mac")),
override var selectedRuntimeId: String? = null,
Expand Down Expand Up @@ -375,7 +501,14 @@ class RuntimeCatalogRepositoryTest {
)
}

override suspend fun listAgents(): List<OpenCodeAgent> = listOf(OpenCodeAgent("build", mode = "primary"))
/** Answers for successive [listAgents] calls; once used up, the default single `build`. */
val agentAnswers = ArrayDeque<() -> List<OpenCodeAgent>>()
var agentCalls = 0

override suspend fun listAgents(): List<OpenCodeAgent> {
agentCalls++
return agentAnswers.removeFirstOrNull()?.invoke() ?: listOf(OpenCodeAgent("build", mode = "primary"))
}

override suspend fun listWorkspaces(): List<WorkspaceRef> =
listOf(
Expand Down
Loading