diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt index 6cc6922cb..c898e79ed 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt @@ -165,8 +165,24 @@ data class OpenCodeAgent( val description: String? = null, val mode: String? = null, val native: Boolean = false, + val hidden: Boolean = false, ) +/** + * The agents a user can start a chat with: OpenCode's primary (and `all`) agents, minus the hidden + * internal ones (compaction, title, summary). Plugin-defined primaries such as oh-my-opencode-slim's + * `orchestrator` are included. + * + * Server order is kept on purpose: OpenCode's `GET /agent` sorts its configured `default_agent` + * first (or `build` when none is set), so [defaultAgentName] follows the server's choice instead of + * hard-coding `build`. + */ +fun List.selectablePrimaryAgents(): List = + filter { (it.mode == null || it.mode == "primary" || it.mode == "all") && !it.hidden } + +/** The server's default agent: the first selectable primary it lists, or null when it offers none. */ +fun List.defaultAgentName(): String? = selectablePrimaryAgents().firstOrNull()?.name + @Serializable data class OpenCodeProject( val id: String, diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt index 6beef9dc0..ac0c39531 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/settings/AppPreferencesRepository.kt @@ -2,6 +2,8 @@ package com.yugahashimoto.andcode.data.settings import com.yugahashimoto.andcode.core.api.OpenCodeAgent import com.yugahashimoto.andcode.core.api.ProviderCatalog +import com.yugahashimoto.andcode.core.api.defaultAgentName +import com.yugahashimoto.andcode.core.api.selectablePrimaryAgents import com.yugahashimoto.andcode.data.connection.SecureSettingsRepository import com.yugahashimoto.andcode.feature.assistant.TtsTuning import com.yugahashimoto.andcode.feature.wakeword.WakeWordGrammar @@ -144,11 +146,10 @@ class AppPreferencesRepository( ) { selectModel(reconciled.providerId, reconciled.modelId) } - val primaryAgents = agents.filter { it.mode == null || it.mode == "primary" } + val primaryAgents = agents.selectablePrimaryAgents() val agentId = current.agentId?.takeIf { selected -> primaryAgents.any { it.name == selected } } - ?: primaryAgents.firstOrNull { it.name == "build" }?.name - ?: primaryAgents.firstOrNull()?.name + ?: agents.defaultAgentName() if (agentId != current.agentId) { selectAgent(agentId) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfile.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfile.kt index cce1cc72d..3a2232219 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfile.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfile.kt @@ -2,6 +2,7 @@ package com.yugahashimoto.andcode.feature.assistant import com.yugahashimoto.andcode.core.api.OpenCodeAgent import com.yugahashimoto.andcode.core.api.OpenCodeProvider +import com.yugahashimoto.andcode.core.api.defaultAgentName import com.yugahashimoto.andcode.data.connection.ConnectionProfile data class AssistantProfile( @@ -53,7 +54,7 @@ object AssistantProfileResolver { val agentId = profile.agentId ?.takeIf { configured -> agents.any { it.name == configured } } - ?: agents.firstOrNull { it.name == "build" }?.name + ?: agents.defaultAgentName() ?: agents.firstOrNull()?.name ResolvedAssistantProfile( 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 673cfcc5e..e3b7a1749 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 @@ -131,6 +131,8 @@ import com.yugahashimoto.andcode.core.api.OpenCodeCommand import com.yugahashimoto.andcode.core.api.OpenCodeProvider import com.yugahashimoto.andcode.core.api.OpenCodeSkill import com.yugahashimoto.andcode.core.api.PromptAttachment +import com.yugahashimoto.andcode.core.api.defaultAgentName +import com.yugahashimoto.andcode.core.api.selectablePrimaryAgents import com.yugahashimoto.andcode.core.diagnostics.StallDiagnosis import com.yugahashimoto.andcode.core.diagnostics.StallReason import com.yugahashimoto.andcode.core.diagnostics.explain @@ -1864,15 +1866,19 @@ private fun ModeChip( onSelect: (String) -> Unit, ) { var expanded by remember { mutableStateOf(false) } - // OpenCode servers can report arbitrary custom agents alongside its two built-ins; this chip - // only offers build/plan to keep the picker to the two modes users actually switch between. - // Fall back to the full list if a server configuration omits both, rather than showing an - // unusable empty dropdown. - val selectableAgents = agents.filter { it.name == "build" || it.name == "plan" }.ifEmpty { agents } + // Every primary agent the server reports is offered, not just build/plan: plugins such as + // oh-my-opencode-slim register their own primary (`orchestrator`), and hiding it left users + // unable to reach it. `agents` is normally narrowed upstream already; filtering again keeps + // subagents and OpenCode's internal compaction/title/summary agents out even if a caller + // passes the raw list. + val selectableAgents = agents.selectablePrimaryAgents() // The selection is remembered across runtimes, so an id this runtime does not offer would // otherwise label the chip with another agent's name. An empty list is not a licence to trust // it either: that is exactly the state a stopped runtime is in. - val label = selectedAgentId?.takeIf { id -> selectableAgents.any { it.name == id } } ?: "build" + val label = + selectedAgentId?.takeIf { id -> selectableAgents.any { it.name == id } } + ?: agents.defaultAgentName() + ?: "build" Box { Surface( modifier = 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 6b8e6121b..d8e741171 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 @@ -6,6 +6,7 @@ import com.yugahashimoto.andcode.core.api.OpenCodeAgent import com.yugahashimoto.andcode.core.api.OpenCodeProvider import com.yugahashimoto.andcode.core.api.ProviderAuthMethod import com.yugahashimoto.andcode.core.api.ProviderCatalog +import com.yugahashimoto.andcode.core.api.selectablePrimaryAgents import com.yugahashimoto.andcode.core.security.SecretRedaction import com.yugahashimoto.andcode.data.connection.SecureSettingsRepository import com.yugahashimoto.andcode.data.repository.RuntimeCatalogRepository @@ -204,7 +205,7 @@ class SettingsViewModel( providers = core.runtime.providers.all.filter { it.id in chatConnected }, availableProviders = managed.all, connectedProviderIds = (managed.connected.toSet() + oauth.locallyConnected) - oauth.locallyDisconnected, - agents = core.runtime.agents.filter { it.mode == null || it.mode == "primary" }, + agents = core.runtime.agents.selectablePrimaryAgents(), providerId = core.preferences.providerId, modelId = core.preferences.modelId, agentId = core.preferences.agentId, diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeAgentSelectionTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeAgentSelectionTest.kt new file mode 100644 index 000000000..cbb34e6f6 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeAgentSelectionTest.kt @@ -0,0 +1,77 @@ +package com.yugahashimoto.andcode.core.api + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class OpenCodeAgentSelectionTest { + // The shape OpenCode's GET /agent returns with oh-my-opencode-slim installed and + // `default_agent: "orchestrator"`: the default first, then build, then the rest by name. + private val pluginAgents = + listOf( + OpenCodeAgent("orchestrator", mode = "primary"), + OpenCodeAgent("build", mode = "primary", native = true), + OpenCodeAgent("compaction", mode = "primary", native = true, hidden = true), + OpenCodeAgent("council", mode = "subagent"), + OpenCodeAgent("councillor-gpt", mode = "subagent", hidden = true), + OpenCodeAgent("fixer", mode = "subagent"), + OpenCodeAgent("plan", mode = "primary", native = true), + OpenCodeAgent("summary", mode = "primary", native = true, hidden = true), + OpenCodeAgent("title", mode = "primary", native = true, hidden = true), + ) + + @Test + fun `offers plugin primaries alongside build and plan, but not subagents or hidden agents`() { + assertEquals( + listOf("orchestrator", "build", "plan"), + pluginAgents.selectablePrimaryAgents().map { it.name }, + ) + } + + @Test + fun `offers agents whose mode is all or unreported`() { + val agents = + listOf( + OpenCodeAgent("build", mode = "primary"), + OpenCodeAgent("both", mode = "all"), + OpenCodeAgent("legacy"), + ) + + assertEquals(listOf("build", "both", "legacy"), agents.selectablePrimaryAgents().map { it.name }) + } + + @Test + fun `defaults to the agent the server lists first, which is its default_agent`() { + assertEquals("orchestrator", pluginAgents.defaultAgentName()) + } + + @Test + fun `defaults to build on a stock server`() { + val stock = + listOf( + OpenCodeAgent("build", mode = "primary", native = true), + OpenCodeAgent("plan", mode = "primary", native = true), + ) + + assertEquals("build", stock.defaultAgentName()) + } + + @Test + fun `has no default when the runtime reports no agents`() { + assertNull(emptyList().defaultAgentName()) + } + + @Test + fun `decodes the hidden flag and treats a missing one as visible`() { + val json = Json { ignoreUnknownKeys = true } + val agents = + json.decodeFromString>( + """[{"name":"title","mode":"primary","hidden":true,"native":true},{"name":"orchestrator","mode":"primary"}]""", + ) + + assertTrue(agents[0].hidden) + assertEquals(listOf("orchestrator"), agents.selectablePrimaryAgents().map { it.name }) + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfileTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfileTest.kt index 6854f5238..bced598b8 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfileTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/assistant/AssistantProfileTest.kt @@ -42,6 +42,25 @@ class AssistantProfileTest { assertEquals("build", result.agentId) } + @Test + fun `missing agent follows the server's default_agent rather than build`() { + val result = + AssistantProfileResolver.resolve( + profile = AssistantProfile(backendId = "mac"), + connections = listOf(connection), + providers = listOf(provider), + defaultModels = mapOf("opencode" to "free"), + agents = + listOf( + OpenCodeAgent(name = "orchestrator", mode = "primary"), + OpenCodeAgent(name = "build", mode = "primary"), + OpenCodeAgent(name = "fixer", mode = "subagent"), + ), + ).getOrThrow() + + assertEquals("orchestrator", result.agentId) + } + @Test fun `deleted backend produces configuration error`() { val result =