diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt index b2bdd07a..da2f9e33 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/NewSessionScreen.kt @@ -328,8 +328,23 @@ internal fun NewSessionBody( } // --- Effort --- + // An agent whose levels differ by model lists none of its own: + // the chosen model's (or its default model's) are offered, its + // own default among them. + val chosenModel = modelOptions.firstOrNull { it.id == model.ifEmpty { agentModels?.defaultModel.orEmpty() } } + val modelEfforts = chosenModel?.efforts.orEmpty() val efforts = agent?.efforts.orEmpty() - if (efforts.isNotEmpty()) { + if (modelEfforts.isNotEmpty()) { + if (shown++ > 0) Divider() + ValueRow("Effort") { + SelectField( + options = modelEfforts.map { PickerOption(it.id, it.label) }, + // A level the model lacks reads as its default. + selected = effort.takeIf { pick -> modelEfforts.any { it.id == pick } } ?: modelEfforts.first().id, + onSelect = { effort = it }, + ) + } + } else if (efforts.isNotEmpty()) { if (shown++ > 0) Divider() ValueRow("Effort") { SelectField( diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt index 801f7c07..6de1d218 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt @@ -98,6 +98,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withTimeoutOrNull import uniffi.client_ffi.UniffiIntent +import uniffi.client_ffi.UniffiModelEntry import uniffi.client_ffi.UniffiOptionChoice import uniffi.client_ffi.UniffiSessionMcp import uniffi.client_ffi.UniffiTranscriptDelta @@ -495,13 +496,21 @@ fun SessionScreen( inputFocus.requestFocus() } - // Refresh the subscription-usage snapshot on open: the bridge only - // publishes usage when asked. An agent whose catalog entry says it has - // no usage is not asked (it would publish nothing); until the catalog - // is known the effect waits, and fires once it says yes. + // Refresh the usage snapshot on open and whenever a turn ends (the + // session's cost grows with each): the bridge only publishes usage when + // asked. An agent whose catalog entry says it has no usage is not asked + // (it would publish nothing); until the catalog is known the effect + // waits, and fires once it says yes. + // The agent's model list names the session's model in the controls bar. + LaunchedEffect(machine, agent?.id, agent?.supportsModels) { + val id = agent?.id + if (id != null && agent.supportsModels) core.dispatch(UniffiIntent.RequestModels(machine = machine, agent = id)) + } + val supportsUsage = agent?.supportsUsage == true - LaunchedEffect(machine, sessionId, supportsUsage) { - if (supportsUsage) core.dispatch(UniffiIntent.RequestUsage(machine = machine, sessionId = sessionId)) + val turnRunning = session?.state == "running" + LaunchedEffect(machine, sessionId, supportsUsage, turnRunning) { + if (supportsUsage && !turnRunning) core.dispatch(UniffiIntent.RequestUsage(machine = machine, sessionId = sessionId)) } // Slash commands: the menu opens while the draft is a bare `/name` (not @@ -666,11 +675,20 @@ fun SessionScreen( SessionControlsBar( effort = session?.effort, - efforts = agent?.efforts.orEmpty(), + // The session model's own levels, for an agent whose levels + // differ by model; else the agent's. + efforts = machineSummary?.models?.firstOrNull { it.agent == session?.agent }?.models + ?.firstOrNull { it.id == session?.model }?.efforts?.takeIf { it.isNotEmpty() } + ?: agent?.efforts.orEmpty(), // The button shows only when the agent has modes to switch between. modeLabel = modeLabel?.takeIf { modes.size >= 2 }, modePending = modeCycle.pending != null, model = session?.model, + modelName = listedModelName( + session?.model, + listOfNotNull(machineSummary?.models?.firstOrNull { it.agent == session?.agent }?.models) + + machineSummary?.providerProfiles.orEmpty().filter { it.agent == session?.agent }.map { it.models }, + ), contextPercentage = session?.contextPercentage, contextWindow = session?.contextWindow?.toLong(), onEffortSelect = { level -> @@ -855,6 +873,8 @@ internal fun SessionControlsBar( /** `null` hides the MCP pill (no servers, or an agent without MCP). */ mcp: UniffiSessionMcp? = null, onMcpTap: () -> Unit = {}, + /** The model list's name for [model] ([listedModelName]); null shows its tag. */ + modelName: String? = null, ) { Row( Modifier @@ -865,7 +885,7 @@ internal fun SessionControlsBar( horizontalArrangement = Arrangement.spacedBy(Tokens.Space2), ) { if (model != null) { - ModelContextChip(model = model, contextPercentage = contextPercentage, contextWindow = contextWindow) + ModelContextChip(model = model, modelName = modelName, contextPercentage = contextPercentage, contextWindow = contextWindow) } if (modeLabel != null) { ModeButton(modeLabel, modePending, onModeTap) @@ -876,6 +896,9 @@ internal fun SessionControlsBar( if (mcp != null) { McpChip(mcp, onMcpTap) } + // What the session has cost so far: the agent's figure, shown whether + // or not the subscription windows are. + usage?.sessionCostUsd?.let(::sessionCost)?.let { CostPill(it) } val badges = usageBadges(usage, System.currentTimeMillis()) if (showUsageBadge && badges.isNotEmpty()) { UsageBox(usage, badges) @@ -916,6 +939,31 @@ internal fun SendFailedBar(text: String, failedCount: Int, onRetry: () -> Unit) } } +/** A session's cost as a pill: "$0.42", "<$0.01" below a cent; nothing for + * a free (or unpriced) session. */ +internal fun sessionCost(usd: Double): String? = when { + !usd.isFinite() || usd <= 0.0 -> null + usd < 0.01 -> "<$0.01" + usd < 100.0 -> "$" + String.format(java.util.Locale.ROOT, "%.2f", usd) + else -> "$" + String.format(java.util.Locale.ROOT, "%.0f", usd) +} + +@Composable +private fun CostPill(cost: String) { + Text( + cost, + color = Tokens.TextMuted, + fontSize = Tokens.TextXs, + fontWeight = FontWeight.Bold, + maxLines = 1, + modifier = Modifier + .clip(RoundedCornerShape(Tokens.RadiusPill)) + .background(Tokens.SurfaceHover) + .padding(horizontal = ControlPadH, vertical = ControlPadV) + .semantics { contentDescription = "Session cost $cost" }, + ) +} + /** Effort dropdown over the agent's advertised levels: shows the current * level ("effort…" until the bridge reports one), opens the list on tap. The * placeholder carries the muted "effort…" trigger for the not-yet-reported @@ -1078,6 +1126,18 @@ internal fun modelLabel(id: String?): String { return base.replace(Regex("^claude-"), "").replace(Regex("-\\d{8}$"), "") } +/** + * The name a model list gives the session's model [id] — its agent's list, + * or a provider profile's — for a model [MODEL_TAGS] has no compact tag + * for: an OpenCode or gateway id such as `ccr/OpenCode Go/deepseek-v4.1-flash` + * reads as `deepseek-v4.1-flash`, as in the model picker. Null when the tag + * table knows it, or no list names it. + */ +internal fun listedModelName(id: String?, lists: List>): String? { + if (id.isNullOrEmpty() || MODEL_TAGS.any { (modelId) -> modelId == stripContextMarker(id) }) return null + return lists.firstNotNullOfOrNull { list -> list.firstOrNull { it.id == id }?.label?.takeIf { it.isNotBlank() } } +} + /** Widest the model tag in [ModelContextChip] gets before it marquees. */ private val MODEL_TAG_MAX_WIDTH = 160.dp @@ -1109,13 +1169,15 @@ private val MODEL_TAG_MAX_WIDTH = 160.dp @Composable private fun ModelContextChip( model: String, + /** The model list's name for it ([listedModelName]), when it has one. */ + modelName: String?, contextPercentage: Double?, contextWindow: Long?, ) { Layout( content = { Text( - modelLabel(model), + modelName ?: modelLabel(model), color = Tokens.Accent, fontWeight = FontWeight.Bold, fontSize = Tokens.TextXs, diff --git a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt index a5ac35d5..1ebaa56f 100644 --- a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt +++ b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt @@ -6119,6 +6119,12 @@ data class UniffiModelEntry ( * How many tokens the model takes in, when known. */ var `contextWindow`: kotlin.UInt? + , + /** + * The model's own reasoning levels, for an agent whose levels differ by + * model; empty: the agent's. + */ + var `efforts`: List ){ @@ -6139,6 +6145,7 @@ public object FfiConverterTypeUniffiModelEntry: FfiConverterRustBuffer UniffiOptionChoice(id, label, null) }, + ), + UniffiModelEntry("opencode/big-pickle", "Big Pickle", "OpenCode Zen", null, emptyList()), + ), + "home-gateway/OpenCode Go/deepseek-v4.1-flash", + null, )), providerProfiles = providerProfiles, plugins = listOf(claudePlugins, opencodePlugins), mcp = listOf(claudeMcp), directAdvertised = listOf("wss://192.168.1.20:7447"), directPinned = true, diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt index 812a10b2..ac56338c 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt @@ -35,6 +35,7 @@ import com.codedeck.plus.ui.transcript.DisplayEntry import com.codedeck.plus.ui.screens.AccountPage import com.codedeck.plus.ui.screens.BackupPage import com.codedeck.plus.ui.screens.RestoreContent +import uniffi.client_ffi.UniffiUsageData import uniffi.client_ffi.UniffiBackupStatus import uniffi.client_ffi.UniffiBackupView import com.codedeck.plus.ui.screens.SettingsHub @@ -93,6 +94,16 @@ private val pages: Map Unit> = linkedMapOf( ) } }, + "session_controls_opencode" to { + Column(Modifier.background(Tokens.Bg)) { + SessionControlsBar( + effort = null, efforts = emptyList(), modeLabel = "Plan", modePending = false, model = "home-gateway/OpenCode Go/deepseek-v4.1-flash", + contextPercentage = 18.0, contextWindow = 1_000_000, onEffortSelect = {}, onModeTap = {}, showUsageBadge = true, + usage = UniffiUsageData(available = true, plan = null, windows = emptyList(), sessionCostUsd = 0.0371, fetchedAt = "2026-10-08T10:00:00Z"), + modelName = "deepseek-v4.1-flash", + ) + } + }, "session_commands" to { Column(Modifier.background(Tokens.Bg)) { SessionTopBar(title = "Fix the flaky reconnect test", workspace = "/home/me/code/codedeck-plus", sessionState = "idle", onBack = {}) @@ -156,6 +167,9 @@ private val pages: Map Unit> = linkedMapOf( "new_session" to { NewSessionBody(machine = workstation, events = MutableSharedFlow(), dispatch = {}, onClose = {}, onCreated = {}) }, + "new_session_opencode" to { + NewSessionBody(machine = workstation.copy(defaultAgent = "opencode"), events = MutableSharedFlow(), dispatch = {}, onClose = {}, onCreated = {}) + }, "pairing" to { CompositionLocalProvider(LocalActivityResultRegistryOwner provides DesignFixtures.noResults) { PairingBody( @@ -276,12 +290,14 @@ class DesignSnapshotTest { @Test fun home() = paparazzi.page("home") @Test fun home_nothing_paired() = paparazzi.page("home_nothing_paired") @Test fun session() = paparazzi.page("session") + @Test fun session_controls_opencode() = paparazzi.page("session_controls_opencode") @Test fun session_commands() = paparazzi.page("session_commands") @Test fun transcript() = paparazzi.page("transcript") @Test fun transcript_plan() = paparazzi.page("transcript_plan") @Test fun transcript_cards() = paparazzi.page("transcript_cards") @Test fun transcript_long() = paparazzi.page("transcript_long") @Test fun new_session() = paparazzi.page("new_session") + @Test fun new_session_opencode() = paparazzi.page("new_session_opencode") @Test fun pairing() = paparazzi.page("pairing") @Test fun settings() = paparazzi.page("settings") @Test fun plugins() = paparazzi.page("plugins") diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/components/ModelPickerOptionsTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/components/ModelPickerOptionsTest.kt index 63bb178f..400a01ef 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/components/ModelPickerOptionsTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/components/ModelPickerOptionsTest.kt @@ -9,9 +9,9 @@ class ModelPickerOptionsTest { fun `models are grouped by provider, in the order providers first appear`() { val options = modelPickerOptions( listOf( - UniffiModelEntry("OpenCode Go/glm-5.3-flash", "glm-5.3-flash", "OpenCode Go", null), - UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Z.ai", null), - UniffiModelEntry("OpenCode Go/minimax-m3", null, "OpenCode Go", null), + UniffiModelEntry("OpenCode Go/glm-5.3-flash", "glm-5.3-flash", "OpenCode Go", null, emptyList()), + UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Z.ai", null, emptyList()), + UniffiModelEntry("OpenCode Go/minimax-m3", null, "OpenCode Go", null, emptyList()), ), ) assertEquals( diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/session/ModelLabelTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/ModelLabelTest.kt index ca4596d0..323422ea 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/session/ModelLabelTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/ModelLabelTest.kt @@ -1,7 +1,9 @@ package com.codedeck.plus.ui.session import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test +import uniffi.client_ffi.UniffiModelEntry /** Port of `apps/mobile/src/ui/__tests__/modelLabel.test.ts` — the same * cases, asserting the Kotlin port of `modelLabel` (SessionScreen.kt) keeps @@ -45,4 +47,15 @@ class ModelLabelTest { assertEquals("?", modelLabel(null)) assertEquals("?", modelLabel("")) } + + @Test + fun aListedModelReadsByItsListName() { + val opencode = listOf(UniffiModelEntry("ccr/OpenCode Go/deepseek-v4.1-flash", "deepseek-v4.1-flash", "CCR · OpenCode Go", null, emptyList())) + val profile = listOf(UniffiModelEntry("kimi-k3", "Kimi K3", "Moonshot", null, emptyList())) + assertEquals("deepseek-v4.1-flash", listedModelName("ccr/OpenCode Go/deepseek-v4.1-flash", listOf(opencode, profile))) + assertEquals("Kimi K3", listedModelName("kimi-k3", listOf(opencode, profile))) + // Claude's compact tags stay; an unlisted model has no name. + assertNull(listedModelName("claude-opus-5", listOf(listOf(UniffiModelEntry("claude-opus-5", "Opus 5", null, null, emptyList()))))) + assertNull(listedModelName("unlisted/model", listOf(opencode))) + } } diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionChromeSnapshotTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionChromeSnapshotTest.kt index b651d8b5..39e502b4 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionChromeSnapshotTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionChromeSnapshotTest.kt @@ -74,7 +74,7 @@ class SessionChromeSnapshotTest { UniffiUsageWindow(label = "5h", utilization = 61.0, resetsAt = null), UniffiUsageWindow(label = "7d", utilization = 23.0, resetsAt = null), ), - sessionCostUsd = null, + sessionCostUsd = 0.42, fetchedAt = "2026-09-23T10:00:00Z", ), ) diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionCostTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionCostTest.kt new file mode 100644 index 00000000..7ee1e8e4 --- /dev/null +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/session/SessionCostTest.kt @@ -0,0 +1,17 @@ +package com.codedeck.plus.ui.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionCostTest { + @Test + fun a_cost_reads_in_dollars_and_a_free_session_shows_none() { + assertEquals("$0.42", sessionCost(0.4213)) + assertEquals("<$0.01", sessionCost(0.004)) + assertEquals("$12.30", sessionCost(12.3)) + assertEquals("$140", sessionCost(139.6)) + assertNull(sessionCost(0.0)) + assertNull(sessionCost(Double.NaN)) + } +} diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_machine.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_machine.png index 352c8fb1..e4c350cf 100644 Binary files a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_machine.png and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_machine.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_new_session_opencode.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_new_session_opencode.png new file mode 100644 index 00000000..e410cf70 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_new_session_opencode.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_session_controls_opencode.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_session_controls_opencode.png new file mode 100644 index 00000000..6200265e Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_session_controls_opencode.png differ diff --git a/crates/agent-protocol/src/messages.rs b/crates/agent-protocol/src/messages.rs index 6f26d665..e442ebd8 100644 --- a/crates/agent-protocol/src/messages.rs +++ b/crates/agent-protocol/src/messages.rs @@ -195,6 +195,11 @@ pub enum SessionEvent { /// plan approval switched it). #[serde(default, skip_serializing_if = "Option::is_none")] mode: Option, + /// The agent named the session itself (OpenCode titles a session + /// after its first message). It wins over the title the bridge takes + /// from that message and over the topic it asks the agent for. + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[specta(type = Option)] context_window: Option, diff --git a/crates/bridge-core/src/catalog.rs b/crates/bridge-core/src/catalog.rs index 52fce9fe..c345c62b 100644 --- a/crates/bridge-core/src/catalog.rs +++ b/crates/bridge-core/src/catalog.rs @@ -69,6 +69,9 @@ pub(crate) fn is_mode(agent: &AgentInfo, mode: &str) -> bool { agent.modes.iter().any(|m| m.id == mode) } +/// Whether `effort` may be asked of `agent`: one of its levels, or — for an +/// agent that lists none of its own, its levels differing by model +/// (`ModelEntry.efforts`) — any, which the agent itself checks. pub(crate) fn is_effort(agent: &AgentInfo, effort: &str) -> bool { - agent.efforts.iter().any(|e| e.id == effort) + agent.efforts.is_empty() || agent.efforts.iter().any(|e| e.id == effort) } diff --git a/crates/bridge-core/src/engine/host.rs b/crates/bridge-core/src/engine/host.rs index 3e39d5d7..45999158 100644 --- a/crates/bridge-core/src/engine/host.rs +++ b/crates/bridge-core/src/engine/host.rs @@ -64,6 +64,16 @@ pub(crate) enum HostCall { DeleteConversation(ConversationDelete), } +/// The facts a `SessionEvent::Info` changed; absent = unchanged. +struct InfoUpdate { + native_session_id: Option, + model: Option, + mode: Option, + title: Option, + context_window: Option, + context_percentage: Option, +} + /// A conversation of a deleted session, for its agent to delete. #[derive(Debug, Clone)] pub(crate) struct ConversationDelete { @@ -503,8 +513,9 @@ impl Engine { } match event { SessionEvent::Ready {} => self.on_ready(session_id), - SessionEvent::Info { native_session_id, model, mode, context_window, context_percentage } => { - self.on_info(session_id, native_session_id, model, mode, context_window, context_percentage); + SessionEvent::Info { native_session_id, model, mode, title, context_window, context_percentage } => { + let info = InfoUpdate { native_session_id, model, mode, title, context_window, context_percentage }; + self.on_info(session_id, info); } SessionEvent::Entries { entries } => self.on_entries(session_id, entries), SessionEvent::Turn { state } => { @@ -542,16 +553,15 @@ impl Engine { self.publish_all(BridgeToPhone::SessionReady(SessionReadyMsg { pending_id: session_id.to_string(), session: info })); } - fn on_info( - &mut self, - session_id: &str, - native_session_id: Option, - model: Option, - mode: Option, - context_window: Option, - context_percentage: Option, - ) { + fn on_info(&mut self, session_id: &str, info: InfoUpdate) { + let InfoUpdate { native_session_id, model, mode, title, context_window, context_percentage } = info; let Some(session) = self.sessions.get_mut(session_id) else { return }; + let title = title.and_then(|t| title_from(&t)); + if title.is_some() { + if let Some(run) = session.run.as_mut() { + run.agent_titled = true; + } + } let rec = &mut session.rec; let mut changed = false; let mut set = |field: &mut Option, value: Option| { @@ -562,6 +572,7 @@ impl Engine { }; set(&mut rec.native_session_id, native_session_id); set(&mut rec.model, model); + set(&mut rec.title, title); let mode_changed = mode.is_some() && rec.mode != mode; set(&mut rec.mode, mode.clone()); if context_window.is_some_and(|w| w > 0) && rec.context_window != context_window { @@ -606,7 +617,7 @@ impl Engine { *text = stripped; if let Some(meta) = meta.filter(|_| !run.summarized) { run.summarized = true; - if let Some(topic) = meta.topic { + if let Some(topic) = meta.topic.filter(|_| !run.agent_titled) { session.rec.title = Some(topic); } if let Some(project) = meta.project { diff --git a/crates/bridge-core/src/session.rs b/crates/bridge-core/src/session.rs index 5a788127..734ed8be 100644 --- a/crates/bridge-core/src/session.rs +++ b/crates/bridge-core/src/session.rs @@ -71,6 +71,9 @@ pub(crate) struct Runner { pub meta_requested: bool, /// The session-meta tag has been read once; later ones are only stripped. pub summarized: bool, + /// The agent named the session itself: a session-meta topic no longer + /// renames it. + pub agent_titled: bool, /// git HEAD when the session started; a different HEAD later = a commit. pub base_head: Option, } @@ -89,6 +92,7 @@ impl Runner { authored: VecDeque::new(), meta_requested: titled, summarized: titled, + agent_titled: false, base_head: None, } } diff --git a/crates/bridge-core/tests/cards.rs b/crates/bridge-core/tests/cards.rs index e6836f9a..cbc5c74c 100644 --- a/crates/bridge-core/tests/cards.rs +++ b/crates/bridge-core/tests/cards.rs @@ -178,7 +178,7 @@ fn a_plan_answer_and_the_agents_mode_switch_reach_the_phone() { ); assert_eq!(resolved(&rig.messages()), ["YOLO"]); - rig.host_event(&s, SessionEvent::Info { native_session_id: None, model: None, mode: Some("yolo".into()), context_window: None, context_percentage: None }); + rig.host_event(&s, SessionEvent::Info { native_session_id: None, model: None, mode: Some("yolo".into()), title: None, context_window: None, context_percentage: None }); let msgs = rig.messages(); assert!(msgs.iter().any(|m| matches!(m, BridgeToPhone::OptionConfirmed(o) if o.value == "yolo"))); assert_eq!(last_heartbeat(&msgs).sessions[0].mode.as_deref(), Some("yolo")); @@ -300,8 +300,12 @@ fn set_option_is_checked_against_the_catalog_then_confirmed() { rig.host_reply(&id, HostMessage::Error { message: "no".into() }); assert_eq!(confirmed(&mut rig), ["low"], "a refused change confirms the effort in force"); + // Beta lists no levels of its own: its levels are per model, so the + // agent judges one — and its refusal confirms nothing new. let b = rig.ready_session("beta"); - assert_eq!(set_option(&mut rig, &b, "effort", "low"), None, "beta has no efforts"); + let id = set_option(&mut rig, &b, "effort", "max").expect("left to the agent"); + rig.host_reply(&id, HostMessage::Error { message: "not a level of this model".into() }); + assert!(!confirmed(&mut rig).contains(&"max".to_string())); } #[test] @@ -499,7 +503,7 @@ fn models_are_listed_or_the_phone_is_told_why_not() { rig.host_up(); rig.send(json!({"type":"models-request","agent":"alpha"})); let (id, _) = rig.host_request(|m| matches!(m, BridgeMessage::ListModels { .. })); - rig.host_reply(&id, HostMessage::Models { models: vec![protocol::events::ModelEntry { id: "m1".into(), label: None, provider: None }], default_model: Some("m1".into()) }); + rig.host_reply(&id, HostMessage::Models { models: vec![protocol::events::ModelEntry { id: "m1".into(), ..Default::default() }], default_model: Some("m1".into()) }); let msgs = rig.messages(); assert!(msgs.iter().any(|m| matches!(m, BridgeToPhone::Models(x) if x.models.len() == 1 && x.error.is_none() && x.default_model.as_deref() == Some("m1")))); diff --git a/crates/bridge-core/tests/sessions.rs b/crates/bridge-core/tests/sessions.rs index b0c13a89..31f289b8 100644 --- a/crates/bridge-core/tests/sessions.rs +++ b/crates/bridge-core/tests/sessions.rs @@ -198,6 +198,7 @@ fn a_session_started_on_the_default_model_is_listed_with_it() { native_session_id: None, model: Some("m-default".into()), mode: None, + title: None, context_window: None, context_percentage: None, }); @@ -291,6 +292,30 @@ fn the_meta_tag_titles_the_session_once_and_is_always_stripped() { assert!(heartbeats(&msgs).iter().all(|h| h.sessions[0].title.as_deref() == Some("Fix login"))); } +#[test] +fn a_title_the_agent_gives_wins_over_the_meta_topic() { + let mut rig = Rig::new(); + rig.host_up(); + let s = rig.ready_session("alpha"); + let info = |title: &str| SessionEvent::Info { + native_session_id: None, + model: None, + mode: None, + title: Some(title.into()), + context_window: None, + context_percentage: None, + }; + rig.host_event(&s, info("Fix the login redirect")); + rig.say(&s, "Done.\n"); + let hb = last_heartbeat(&rig.messages()); + assert_eq!((hb.sessions[0].title.as_deref(), hb.sessions[0].project.as_str()), (Some("Fix the login redirect"), "web")); + // A later title from the agent still renames it; a blank one does not. + rig.host_event(&s, info("Fix the OAuth login redirect")); + rig.host_event(&s, info(" ")); + let hb = last_heartbeat(&rig.messages()); + assert_eq!(hb.sessions[0].title.as_deref(), Some("Fix the OAuth login redirect")); +} + #[test] fn input_to_an_unknown_session_is_no_session_and_to_an_ended_one_error() { let mut rig = Rig::new(); @@ -319,7 +344,7 @@ fn turn_events_drive_the_listed_state() { fn info_native(rig: &mut Rig, s: &str, id: &str) { rig.host_event( s, - SessionEvent::Info { native_session_id: Some(id.into()), model: None, mode: None, context_window: None, context_percentage: None }, + SessionEvent::Info { native_session_id: Some(id.into()), model: None, mode: None, title: None, context_window: None, context_percentage: None }, ); } diff --git a/crates/client-core/src/stores/machines.rs b/crates/client-core/src/stores/machines.rs index 8a45bedc..43de3e5e 100644 --- a/crates/client-core/src/stores/machines.rs +++ b/crates/client-core/src/stores/machines.rs @@ -1367,7 +1367,7 @@ mod tests { .map(|id| ModelEntry { id: (*id).to_string(), label: Some(id.to_uppercase()), - provider: None, + ..Default::default() }) .collect(), default_model: default.map(str::to_string), @@ -1607,7 +1607,7 @@ mod tests { "pk", &ModelsMsg { agent: "opencode".into(), - models: vec![ModelEntry { id: "gpt".into(), label: None, provider: None }], + models: vec![ModelEntry { id: "gpt".into(), ..Default::default() }], default_model: None, error: None, }, diff --git a/crates/client-ffi/src/views.rs b/crates/client-ffi/src/views.rs index 255c5f03..f0e671e2 100644 --- a/crates/client-ffi/src/views.rs +++ b/crates/client-ffi/src/views.rs @@ -330,12 +330,21 @@ pub struct UniffiModelEntry { pub provider: Option, /// How many tokens the model takes in, when known. pub context_window: Option, + /// The model's own reasoning levels, for an agent whose levels differ by + /// model; empty: the agent's. + pub efforts: Vec, } fn to_uniffi_model_entries(models: &[protocol::events::ModelEntry]) -> Vec { models .iter() - .map(|m| UniffiModelEntry { id: m.id.clone(), label: m.label.clone(), provider: m.provider.clone(), context_window: None }) + .map(|m| UniffiModelEntry { + id: m.id.clone(), + label: m.label.clone(), + provider: m.provider.clone(), + context_window: None, + efforts: m.efforts.iter().map(to_uniffi_option_choice).collect(), + }) .collect() } @@ -356,6 +365,7 @@ fn to_uniffi_profile_model(profile: &str, m: &protocol::common::ProviderModel) - None => profile.to_string(), }), context_window: m.context_window, + efforts: Vec::new(), } } diff --git a/crates/client-runtime/src/dispatch.rs b/crates/client-runtime/src/dispatch.rs index a88508d4..6839dc77 100644 --- a/crates/client-runtime/src/dispatch.rs +++ b/crates/client-runtime/src/dispatch.rs @@ -1310,7 +1310,7 @@ mod tests { models: vec![protocol::events::ModelEntry { id: "sonnet".into(), label: Some("Sonnet".into()), - provider: None, + ..Default::default() }], default_model: Some("sonnet".into()), error: None, diff --git a/crates/protocol/fixtures/corpus.json b/crates/protocol/fixtures/corpus.json index 5438f6eb..a6c74f3b 100644 --- a/crates/protocol/fixtures/corpus.json +++ b/crates/protocol/fixtures/corpus.json @@ -129,6 +129,7 @@ { "type": "usage", "sessionId": "s", "usage": { "available": false, "fetchedAt": "t" } }, { "type": "gsd-state", "sessionId": "s", "gsd": { "installed": true, "available": true, "hasGit": true, "situation": "x", "summary": "y", "milestone": null, "currentPhase": "2", "totalPhases": 5, "percent": 40.0, "phases": [{ "number": "1", "name": "plan", "diskStatus": "ok", "plans": 2, "summaries": 1, "recentlyTouched": true, "action": null, "command": null, "planCount": 2, "needsYou": null }], "actions": [{ "id": "a", "label": "go", "command": "gsd go", "recommended": true }], "recommended": "a", "paused": false, "blockers": [], "verifyFailed": false, "execution": { "phase": "2", "plansTotal": 3, "plansDone": 1, "currentPlan": "p2", "tasksDone": 4, "tasksTotal": null, "lastTask": "t" } } }, { "type": "models", "agent": "claude-code", "models": [{ "id": "m1", "label": "M1" }, { "id": "m2" }, { "id": "Z.ai/glm-5.3-flash", "label": "glm-5.3-flash", "provider": "Z.ai" }], "defaultModel": "m1" }, + { "type": "models", "agent": "opencode", "models": [{ "id": "ccr/OpenCode Go/deepseek-v4.1-flash", "label": "deepseek-v4.1-flash", "provider": "CCR · OpenCode Go", "efforts": [{ "id": "default", "label": "Default" }, { "id": "high", "label": "High" }, { "id": "max", "label": "Max" }] }], "defaultModel": "ccr/OpenCode Go/deepseek-v4.1-flash" }, { "type": "models", "agent": "opencode", "models": [], "error": "sdk offline" }, { "type": "commands", "sessionId": "s", "commands": [{ "name": "compact", "description": "Clear history but keep a summary", "argumentHint": "" }, { "name": "commit-commands:commit" }] }, { "type": "commands", "sessionId": "s", "commands": [], "error": "The session is not running." }, diff --git a/crates/protocol/src/events.rs b/crates/protocol/src/events.rs index 80ba44e5..b7d0bedc 100644 --- a/crates/protocol/src/events.rs +++ b/crates/protocol/src/events.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use super::capabilities::BridgeHostKind; use super::common::{ - AgentDescriptor, AvailablePlugin, CredentialStatus, GsdState, InstalledPlugin, McpAction, McpServerInfo, OutputEntry, + AgentDescriptor, AvailablePlugin, CredentialStatus, GsdState, InstalledPlugin, McpAction, McpServerInfo, OptionChoice, OutputEntry, SessionMcpServer, PluginAction, PluginMarketplace, ProviderProfileInfo, RemoteSessionInfo, SessionOption, UsageData, @@ -208,7 +208,7 @@ pub struct GsdStateMsg { pub gsd: GsdState, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)] pub struct ModelEntry { pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -217,6 +217,11 @@ pub struct ModelEntry { /// says: the same model can be offered by more than one provider. #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, + /// The reasoning levels of this model, for an agent whose levels differ + /// by model (its catalog entry lists none of its own). Empty: the + /// agent's own levels, if any. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub efforts: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] diff --git a/docs/AGENT-CANDIDATES.md b/docs/AGENT-CANDIDATES.md index 232529a2..d6eb8f84 100644 --- a/docs/AGENT-CANDIDATES.md +++ b/docs/AGENT-CANDIDATES.md @@ -105,7 +105,12 @@ offer no tool that only works on its vendor's API. OpenCode meets all three: its web search is offered only on its own provider (checked on 1.18.32), its web fetch is local, and a provider profile adds the endpoint's models to its list beside OpenCode Zen's free ones — read from -the endpoint, never typed by hand. The server the bridge starts sets +the endpoint, never typed by hand. OpenCode's own catalog (models.dev) +places each profile: an endpoint it knows (DeepSeek's, OpenRouter's) signs +in to that provider as `/connect` would; a gateway's models are filled in +from the catalog — limits, tool calls, image input, and with reasoning +OpenCode's own reasoning levels — but never its prices, a gateway's being +its own. The server the bridge starts sets `OPENCODE_ENABLE_EXA`, so that web search (Exa's keyless endpoint, behind OpenCode's `websearch` permission) is offered to every model, not only to OpenCode's own providers'. Codex is out (its client speaks only the diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index b3615802..1a017f62 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -269,9 +269,12 @@ wrong provider). ### `models` `models-request {agent}` → `models {agent, models[], defaultModel?, error?}`. -Each model is `{id, label?, provider?}`: `provider` names who serves it (an +Each model is `{id, label?, provider?, efforts?}`: `provider` names who serves it (an OpenCode provider, a router's channel such as `OpenCode Go`), since the same -model can be offered by more than one. An empty list always comes with an `error` saying why, so the phone can tell +model can be offered by more than one. `efforts` are the model's own +reasoning levels, from an agent whose levels differ by model: its catalog +entry lists no `efforts`, the phone offers the session model's instead, and +the bridge leaves checking a level to the agent. An empty list always comes with an `error` saying why, so the phone can tell "no answer yet" from a lost message. Models are correlated by the machine that sent them (the event author), never by a payload field. @@ -538,7 +541,7 @@ Session events are notifications: `session-event {sessionId, event}` with | Event | Meaning | |---|---| | `ready` | The agent accepts prompts (once per `start-session`). | -| `info` | Changed facts only: `nativeSessionId` (the resume target), `model`, `mode`, `contextWindow`, `contextPercentage`. | +| `info` | Changed facts only: `nativeSessionId` (the resume target), `model`, `mode`, `title` (the agent named the session; wins over the bridge's title from the first message and the session-meta topic), `contextWindow`, `contextPercentage`. | | `entries` | Transcript entries, in order (the bridge assigns seqs). | | `turn` | `running` / `idle`. | | `ended` | The session is gone: no `error` = a normal end; `resumeLost` = the conversation to resume no longer exists. The host forgets the session. | diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts index cddf7cc2..d0218688 100644 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi } from 'vitest'; import type { Event, OpencodeClient, Provider, Session } from '@opencode-ai/sdk/v2/client'; -import { OpenCodeDriver, pickDefaultModel, toQuestionAnswers } from '../driver'; +import { OpenCodeDriver, reasoningLevels, pickDefaultModel, toQuestionAnswers } from '../driver'; import type { StartSession } from '../../../sdk/types'; import { recordingContext, type Handlers } from '../../../sdk/__tests__/context'; @@ -45,6 +45,38 @@ function start(client: FakeClient, overrides: Partial = {}, handle return ctx; } +describe('OpenCode session titles', () => { + const updated = (title: string, sessionID = 'ses_1') => ({ type: 'session.updated', properties: { sessionID, info: { id: sessionID, title } } }); + + it('creates a session untitled, then reports the title OpenCode gives it, each new one once', async () => { + const client = clientWith([ + updated('New session - 2026-10-08T10:00:00.000Z'), + updated('Fix the login redirect'), + updated('Fix the login redirect'), + updated('Other session', 'ses_other'), + ]); + const ctx = start(client); + await ctx.ended(); + expect(client.session.create).toHaveBeenCalledWith({ directory: '/tmp' }); + expect(ctx.events.filter((e) => e.type === 'info' && 'title' in e)).toEqual([{ type: 'info', title: 'Fix the login redirect' }]); + }); +}); + +describe('OpenCode usage', () => { + it("is the session's cost, none for a free session", async () => { + const client = clientWith([]); + const ctx = recordingContext(); + const session = OpenCodeDriver.withClient(client).startSession({ sessionId: 's1', agent: 'opencode', cwd: '/tmp' }, ctx); + await ctx.waitFor((e) => e.type === 'ready'); + (client.session.get as ReturnType).mockResolvedValue({ data: { id: 'ses_1', cost: 0.0371 } }); + expect(await session.getUsage!()).toMatchObject({ available: true, windows: [], sessionCostUsd: 0.0371 }); + (client.session.get as ReturnType).mockResolvedValue({ data: { id: 'ses_1', cost: 0 } }); + expect(await session.getUsage!()).not.toHaveProperty('sessionCostUsd'); + expect(OpenCodeDriver.withClient(client).info().supports?.usage).toBe(true); + await session.end(); + }); +}); + describe('OpenCode conversation delete', () => { const deleting = (result: unknown) => { const client = clientWith([]) as FakeClient & { session: { delete: ReturnType } }; @@ -377,7 +409,7 @@ describe('OpenCode resume', () => { }); describe('OpenCode options', () => { - it('accepts its modes and provider/model ids, refuses effort', async () => { + it('accepts its modes and provider/model ids', async () => { const ctx = recordingContext(); const session = OpenCodeDriver.withClient(clientWith([])).startSession({ sessionId: 's1', agent: 'opencode', cwd: '/tmp' }, ctx); await session.setOption('mode', 'default'); @@ -385,7 +417,69 @@ describe('OpenCode options', () => { await session.setOption('model', 'anthropic/claude-sonnet-5'); await expect(session.setOption('mode', 'acceptEdits')).rejects.toThrow(/no mode/); await expect(session.setOption('model', 'sonnet')).rejects.toThrow(/provider\/model/); - await expect(session.setOption('effort', 'high')).rejects.toThrow(/no effort/); + await session.end(); + }); +}); + +describe('OpenCode reasoning levels', () => { + const catalog = { + providers: vi.fn().mockResolvedValue({ + data: { + providers: [{ + id: 'ccr', name: 'CCR', + models: { + 'OpenCode Go/deepseek-v4.1-flash': { id: 'OpenCode Go/deepseek-v4.1-flash', name: 'deepseek-v4.1-flash', variants: { low: {}, high: {}, max: {} }, cost: { input: 0, output: 0 }, status: 'active' }, + plain: { id: 'plain', name: 'plain', variants: {}, cost: { input: 0, output: 0 }, status: 'active' }, + }, + }], + default: {}, + }, + error: undefined, + }), + get: vi.fn().mockResolvedValue({ data: {}, error: undefined }), + }; + + it("are the model's own, default first", () => { + expect(reasoningLevels({ low: {}, xhigh: {}, max: {} })).toEqual([ + { id: 'default', label: 'Default' }, + { id: 'low', label: 'Low' }, + { id: 'xhigh', label: 'Extra high' }, + { id: 'max', label: 'Max' }, + ]); + expect(reasoningLevels({})).toEqual([]); + expect(reasoningLevels(undefined)).toEqual([]); + }); + + it('are listed with each model, asked of it on every prompt, and checked against it', async () => { + const client = Object.assign(clientWith([]), { config: catalog }); + const promptAsync = vi.fn().mockResolvedValue({ data: undefined, error: undefined }); + (client.session as unknown as { promptAsync: unknown }).promptAsync = promptAsync; + const driver = OpenCodeDriver.withClient(client); + const listed = (await driver.listModels()).models; + expect(listed.find((m) => m.id === 'ccr/OpenCode Go/deepseek-v4.1-flash')?.efforts?.map((e) => e.id)).toEqual(['default', 'low', 'high', 'max']); + expect(listed.find((m) => m.id === 'ccr/plain')).not.toHaveProperty('efforts'); + + const ctx = recordingContext(); + const session = driver.startSession({ sessionId: 's1', agent: 'opencode', cwd: '/tmp', model: 'ccr/OpenCode Go/deepseek-v4.1-flash' }, ctx); + await ctx.waitFor((e) => e.type === 'ready'); + await session.setOption('effort', 'max'); + session.prompt('think hard'); + await expect.poll(() => promptAsync.mock.calls.length).toBe(1); + expect(promptAsync.mock.calls[0]![0].variant).toBe('max'); + await expect(session.setOption('effort', 'ultra')).rejects.toThrow(/not a reasoning level/); + + // A model without levels drops the level, and refuses one. + await session.setOption('model', 'ccr/plain'); + session.prompt('quick'); + await expect.poll(() => promptAsync.mock.calls.length).toBe(2); + expect(promptAsync.mock.calls[1]![0]).not.toHaveProperty('variant'); + await expect(session.setOption('effort', 'high')).rejects.toThrow(/has no reasoning levels/); + + await session.setOption('model', 'ccr/OpenCode Go/deepseek-v4.1-flash'); + await session.setOption('effort', 'default'); + session.prompt('as usual'); + await expect.poll(() => promptAsync.mock.calls.length).toBe(3); + expect(promptAsync.mock.calls[2]![0]).not.toHaveProperty('variant'); await session.end(); }); }); @@ -533,16 +627,18 @@ describe('OpenCode slash commands', () => { return client as FakeClient & { session: { command: ReturnType; promptAsync: ReturnType } }; }; - it('lists its commands with their argument placeholders as hints', async () => { + it('lists its commands with their argument placeholders as hints, then the ones the driver runs', async () => { const client = withCommands(clientWith([])); const ctx = recordingContext(); const session = OpenCodeDriver.withClient(client).startSession({ sessionId: 's1', agent: 'opencode', cwd: '/tmp', model: 'a/b', resume: 'x' }, ctx); (client.session.get as ReturnType).mockResolvedValue({ data: { id: 'ses_1' }, error: undefined }); - expect(await session.listCommands!()).toEqual([ + const commands = await session.listCommands!(); + expect(commands.slice(0, 3)).toEqual([ { name: 'init', description: 'create/update AGENTS.md' }, { name: 'review', description: 'review changes', argumentHint: '' }, { name: 'fix', argumentHint: ' ' }, ]); + expect(commands.slice(3).map((c) => c.name)).toEqual(['compact', 'undo', 'redo', 'share', 'unshare']); await session.end(); }); @@ -562,6 +658,21 @@ describe('OpenCode slash commands', () => { expect(client.session.promptAsync.mock.calls.map((c) => c[0].parts[0].text).sort()).toEqual(['/etc/hosts is broken', '/unknown thing']); await session.end(); }); + + it('runs a typed /undo itself, never as a prompt, and reports the outcome', async () => { + const client = withCommands(clientWith([])); + const ctx = recordingContext(); + const session = OpenCodeDriver.withClient(client).startSession({ sessionId: 's1', agent: 'opencode', cwd: '/tmp', resume: 'x' }, ctx); + (client.session.get as ReturnType).mockResolvedValue({ data: { id: 'ses_1' }, error: undefined }); + Object.assign(client.session, { + messages: vi.fn().mockResolvedValue({ data: [{ info: { id: 'm1', role: 'user' }, parts: [{ type: 'text', text: 'fix it' }] }] }), + revert: vi.fn().mockResolvedValue({ data: {} }), + }); + session.prompt('/undo'); + await ctx.waitFor(() => ctx.entries().some((e) => e.entryType === 'status' && e.text.startsWith('Undid "fix it"'))); + expect(client.session.promptAsync).not.toHaveBeenCalled(); + await session.end(); + }); }); describe('OpenCode model checks', () => { diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts index eb66505e..1f9d46ee 100644 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts @@ -1,11 +1,13 @@ /** - * Provider profiles as OpenCode providers: the config the server starts - * with, the password guarding it, and the restart a changed list takes. + * Provider profiles as OpenCode providers: where OpenCode's catalog places + * each, the config the server starts with, the password guarding it, and + * the restart a changed list takes. */ import { describe, expect, it, vi } from 'vitest'; -import type { OpencodeClient } from '@opencode-ai/sdk/v2/client'; +import type { Model, OpencodeClient, Provider } from '@opencode-ai/sdk/v2/client'; import { OpenCodeDriver } from '../driver'; -import { admitProfiles, profileModelGroup, profileProviderId, providersConfig, serverSetup } from '../providers'; +import { type Catalog, EMPTY_CATALOG } from '../catalog'; +import { placeProfiles, profileModelGroup, profileProviderId, providersConfig, servedBy, serverSetup } from '../providers'; import type { StartOpenCodeServerOptions } from '../server'; import type { ProviderBinding } from '../../../sdk/types'; @@ -19,9 +21,91 @@ const router = (over: Partial = {}): ProviderBinding => ({ ...over, }); +/** A catalog provider, as OpenCode's server lists it. */ +function catalogProvider(id: string, name: string, url: string, models: Record> = {}): Provider { + return { + id, + name, + source: 'api', + env: [], + options: {}, + models: Object.fromEntries( + Object.entries(models).map(([mid, m]) => [mid, { id: mid, providerID: id, api: { id: mid, url, npm: '@ai-sdk/openai-compatible' }, name: mid, ...m } as Model]), + ), + } as Provider; +} + +const reasoningModel: Partial = { + name: 'DeepSeek V4.1 Flash', + family: 'deepseek', + capabilities: { + temperature: true, reasoning: true, attachment: true, toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: { field: 'reasoning_content' }, + }, + cost: { input: 0.15, output: 0.6, cache: { read: 0.003, write: 0 } }, + limit: { context: 1_000_000, output: 393_216 }, +}; + +const catalog: Catalog = { + providers: new Map([ + ['deepseek', catalogProvider('deepseek', 'DeepSeek', 'https://api.deepseek.com', { 'deepseek-flash': reasoningModel })], + ['opencode-go', catalogProvider('opencode-go', 'OpenCode Go', 'https://opencode.ai/zen/go/v1', { 'deepseek-v4.1-flash': reasoningModel })], + ['openrouter', catalogProvider('openrouter', 'OpenRouter', 'https://openrouter.ai/api/v1', { 'deepseek/deepseek-chat': {} })], + ['opencode', catalogProvider('opencode', 'OpenCode Zen', 'https://opencode.ai/zen/v1')], + ]), + connected: new Set(['opencode', 'deepseek']), +}; + +const providerOf = (config: Record, id: string) => (config.provider as Record>)[id]!; + +describe('placing profiles', () => { + it('names a profile of its own as the user named it', () => { + expect(profileProviderId(router({ label: 'CCR' }))).toBe('ccr'); + expect(profileProviderId(router({ label: 'OpenCode Go (OpenAI)' }))).toBe('opencode-go-openai'); + expect(profileProviderId(router({ label: 'Café / LAN' }))).toBe('cafe-lan'); + expect(profileProviderId(router({ label: '★', id: 'p-7' }))).toBe('p-7'); + }); + + it("signs in to a provider OpenCode knows by its endpoint, unless OpenCode already uses it", () => { + const openrouter = router({ id: 'or', label: 'My OpenRouter', baseUrl: 'https://openrouter.ai/api' }); + const deepseek = router({ id: 'ds', label: 'DeepSeek API', baseUrl: 'https://api.deepseek.com/v1' }); + const { placed, refused } = placeProfiles([openrouter, deepseek], catalog); + expect(placed.map((p) => [p.profile.id, p.providerId, p.known?.id])).toEqual([['or', 'openrouter', 'openrouter']]); + expect(refused).toEqual([{ id: 'ds', reason: 'OpenCode already uses DeepSeek with a key of its own; this profile would replace it.' }]); + }); + + it("leaves out a name one of OpenCode's providers, or an earlier profile, already has", () => { + const ccr = router({ id: 'a', label: 'CCR' }); + const again = router({ id: 'b', label: 'ccr' }); + const named = router({ id: 'c', label: 'DeepSeek' }); + const { placed, refused } = placeProfiles([ccr, again, named], catalog); + expect(placed.map((p) => p.providerId)).toEqual(['ccr']); + expect(refused).toEqual([ + { id: 'b', reason: "The provider profile 'CCR' already goes by 'ccr' in OpenCode. Give this one another name." }, + { id: 'c', reason: "OpenCode already has a provider called 'deepseek'. Give this profile another name." }, + ]); + }); + + it('never takes what profiles already made of the running server for OpenCode\'s own', () => { + // The running server lists the profile's provider, and the catalog one + // it signed in to as connected. + const running: Catalog = { + providers: new Map([...catalog.providers, ['ccr', catalogProvider('ccr', 'CCR', 'http://192.168.1.2:3458/v1', { m: {} })]]), + connected: new Set([...catalog.connected, 'ccr', 'openrouter']), + }; + const served = servedBy(placeProfiles([router({ label: 'CCR' }), router({ id: 'or', label: 'OR', baseUrl: 'https://openrouter.ai/api' })], catalog).placed); + const again = placeProfiles([router({ label: 'CCR' }), router({ id: 'or', label: 'OR', baseUrl: 'https://openrouter.ai/api' })], running, served); + expect(again.refused).toEqual([]); + expect(again.placed.map((p) => p.providerId)).toEqual(['ccr', 'openrouter']); + }); +}); + describe('providersConfig', () => { - it("adds each profile as a provider of its own, beside the operator's, with the token left to the environment", () => { - const config = providersConfig([router()], { provider: { mine: { npm: 'x' } }, theme: 'dark' }); + it("adds a profile of its own beside the operator's providers, with the token left to the environment", () => { + const { placed } = placeProfiles([router()], catalog); + const config = providersConfig(placed, catalog, { provider: { mine: { npm: 'x' } }, theme: 'dark' }); expect(config).toEqual({ theme: 'dark', provider: { @@ -37,37 +121,43 @@ describe('providersConfig', () => { expect(JSON.stringify(config)).not.toContain('tok-secret'); }); - it('names a routed model without its upstream, which its group shows, and gives OpenCode a known context window', () => { - const routed = router({ models: [{ id: 'OpenCode Go/deepseek-v4.1-flash', provider: 'OpenCode Go', contextWindow: 1_000_000 }] }); - const provider = (providersConfig([routed]).provider as Record }>)['home-router']!; - expect(provider.models).toEqual({ - 'OpenCode Go/deepseek-v4.1-flash': { name: 'deepseek-v4.1-flash', limit: { context: 1_000_000, output: 0 } }, + it("fills a gateway's routed model in from the catalog, without its price", () => { + const routed = router({ label: 'CCR', models: [{ id: 'OpenCode Go/deepseek-v4.1-flash', provider: 'OpenCode Go' }] }); + const { placed } = placeProfiles([routed], catalog); + expect(providerOf(providersConfig(placed, catalog), 'ccr').models).toEqual({ + 'OpenCode Go/deepseek-v4.1-flash': { + name: 'deepseek-v4.1-flash', + family: 'deepseek', + limit: { context: 1_000_000, output: 393_216 }, + reasoning: true, + tool_call: true, + temperature: true, + attachment: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + }, }); expect(profileModelGroup('CCR', routed.models[0])).toBe('CCR · OpenCode Go'); expect(profileModelGroup('CCR', { id: 'kimi-k3' })).toBe('CCR'); }); - it('names each provider as the user named the profile', () => { - expect(profileProviderId(router({ label: 'CCR' }))).toBe('ccr'); - expect(profileProviderId(router({ label: 'OpenCode Go (OpenAI)' }))).toBe('opencode-go-openai'); - expect(profileProviderId(router({ label: 'Café / LAN' }))).toBe('cafe-lan'); - expect(profileProviderId(router({ label: '★', id: 'p-7' }))).toBe('p-7'); + it("keeps the endpoint's own context window over the catalog's", () => { + const routed = router({ label: 'CCR', models: [{ id: 'OpenCode Go/deepseek-v4.1-flash', provider: 'OpenCode Go', contextWindow: 128_000 }] }); + const config = providersConfig(placeProfiles([routed], catalog).placed, catalog); + expect((providerOf(config, 'ccr').models as Record)['OpenCode Go/deepseek-v4.1-flash']!.limit).toEqual({ context: 128_000, output: 393_216 }); }); - it("leaves out a profile whose name one of OpenCode's providers, or an earlier profile, already has", () => { - const ccr = router({ id: 'a', label: 'CCR' }); - const again = router({ id: 'b', label: 'ccr' }); - const deepseek = router({ id: 'c', label: 'DeepSeek' }); - const { admitted, refused } = admitProfiles([ccr, again, deepseek], new Set(['deepseek', 'opencode'])); - expect(admitted.map((p) => p.id)).toEqual(['a']); - expect(refused).toEqual([ - { id: 'b', reason: "The provider profile 'CCR' already goes by 'ccr' in OpenCode. Give this one another name." }, - { id: 'c', reason: "OpenCode already has a provider called 'deepseek'. Give this profile another name." }, - ]); + it("signs a known provider in by its key alone, adding only the endpoint's models the catalog lacks", () => { + const openrouter = router({ id: 'or', label: 'My OpenRouter', baseUrl: 'https://openrouter.ai/api', models: [{ id: 'deepseek/deepseek-chat' }, { id: 'new/model', label: 'New' }] }); + const config = providersConfig(placeProfiles([openrouter], catalog).placed, catalog); + expect(providerOf(config, 'openrouter')).toEqual({ + name: 'My OpenRouter', + options: { apiKey: '{env:CODEDECK_PROVIDER_KEY_0}' }, + models: { 'new/model': { name: 'New' } }, + }); }); it('does not restrict the providers OpenCode already has', () => { - const config = providersConfig([router()]); + const config = providersConfig(placeProfiles([router()], catalog).placed, catalog); expect(config).not.toHaveProperty('enabled_providers'); expect(config).not.toHaveProperty('disabled_providers'); expect(config).not.toHaveProperty('model'); @@ -75,23 +165,25 @@ describe('providersConfig', () => { }); describe('serverSetup', () => { + const placedOf = (...profiles: ProviderBinding[]) => placeProfiles(profiles, EMPTY_CATALOG).placed; + it('passes the tokens in the environment and guards the server with a password of its own', () => { - const a = serverSetup([router(), router({ id: 'or', label: 'My OpenRouter', authToken: 'tok-2' })], {}); + const a = serverSetup(placedOf(router(), router({ id: 'or', label: 'My OpenRouter', authToken: 'tok-2' })), EMPTY_CATALOG, {}); expect(a.env.CODEDECK_PROVIDER_KEY_0).toBe('tok-secret'); expect(a.env.CODEDECK_PROVIDER_KEY_1).toBe('tok-2'); expect(JSON.parse(a.env.OPENCODE_CONFIG_CONTENT!).provider).toHaveProperty('my-openrouter'); const password = a.env.OPENCODE_SERVER_PASSWORD!; expect(password).toMatch(/^[0-9a-f]{48}$/); expect(a.headers.authorization).toBe(`Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`); - expect(serverSetup([], {}).env.OPENCODE_SERVER_PASSWORD).not.toBe(password); + expect(serverSetup([], EMPTY_CATALOG, {}).env.OPENCODE_SERVER_PASSWORD).not.toBe(password); // Web search for every model, not only OpenCode's own providers'. - expect(serverSetup([], {}).env.OPENCODE_ENABLE_EXA).toBe('1'); + expect(serverSetup([], EMPTY_CATALOG, {}).env.OPENCODE_ENABLE_EXA).toBe('1'); // No profiles: the operator's config stands as it is. - expect(serverSetup([], { OPENCODE_CONFIG_CONTENT: '{"theme":"x"}' }).env).not.toHaveProperty('OPENCODE_CONFIG_CONTENT'); + expect(serverSetup([], EMPTY_CATALOG, { OPENCODE_CONFIG_CONTENT: '{"theme":"x"}' }).env).not.toHaveProperty('OPENCODE_CONFIG_CONTENT'); }); it("keeps the operator's own environment config underneath", () => { - const { env } = serverSetup([router()], { OPENCODE_CONFIG_CONTENT: '{"provider":{"mine":{"npm":"x"}}}' }); + const { env } = serverSetup(placedOf(router()), EMPTY_CATALOG, { OPENCODE_CONFIG_CONTENT: '{"provider":{"mine":{"npm":"x"}}}' }); expect(Object.keys(JSON.parse(env.OPENCODE_CONFIG_CONTENT!).provider)).toEqual(['mine', 'home-router']); }); }); @@ -189,7 +281,7 @@ describe('an OpenCode driver given provider profiles', () => { return { url: `http://127.0.0.1:${4100 + starts.length}`, pid: starts.length, exited: new Promise(() => {}), close: async () => {} }; }, // The running server lists what it has, the profiles it was given too. - connect: () => ({ provider: { list: async () => ({ data: { all: ids.map((id) => ({ id })), default: {}, connected: [] } }) } }) as unknown as OpencodeClient, + connect: () => ({ provider: { list: async () => ({ data: { all: ids.map((id) => ({ id, name: id, models: {} })), default: {}, connected: [] } }) } }) as unknown as OpencodeClient, }); expect(await driver.setProviders([router({ label: 'DeepSeek' })])).toEqual([ { id: 'router', reason: "OpenCode already has a provider called 'deepseek'. Give this profile another name." }, diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeSessionCommands.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeSessionCommands.test.ts new file mode 100644 index 00000000..faa8f1e0 --- /dev/null +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeSessionCommands.test.ts @@ -0,0 +1,81 @@ +/** + * The commands OpenCode's terminal runs itself (compact, undo, redo, + * share), run through its server API. + */ +import { describe, expect, it, vi } from 'vitest'; +import type { OpencodeClient } from '@opencode-ai/sdk/v2/client'; +import { SESSION_COMMANDS, sessionCommand, sessionSlashCommands, type SessionCommandContext } from '../sessionCommands'; + +const user = (id: string, text: string) => ({ info: { id, role: 'user' }, parts: [{ type: 'text', text }] }); +const assistant = (id: string) => ({ info: { id, role: 'assistant' }, parts: [{ type: 'text', text: 'done' }] }); + +function context(revert?: string) { + const session = { + messages: vi.fn().mockResolvedValue({ data: [user('m1', 'add a test'), assistant('m2'), user('m3', 'now rename it\nplease'), assistant('m4')] }), + get: vi.fn().mockResolvedValue({ data: { id: 'ses_1', ...(revert ? { revert: { messageID: revert } } : {}) } }), + revert: vi.fn().mockResolvedValue({ data: {} }), + unrevert: vi.fn().mockResolvedValue({ data: {} }), + summarize: vi.fn().mockResolvedValue({ data: true }), + share: vi.fn().mockResolvedValue({ data: { share: { url: 'https://opncd.ai/s/abc' } } }), + unshare: vi.fn().mockResolvedValue({ data: {} }), + }; + const lines: string[] = []; + const ctx: SessionCommandContext = { + client: { session } as unknown as OpencodeClient, + sessionID: 'ses_1', + directory: '/w', + model: { providerID: 'ccr', modelID: 'm' }, + status: (text) => lines.push(text), + }; + return { ctx, session, lines }; +} + +const run = (name: string, ctx: SessionCommandContext) => SESSION_COMMANDS.find((c) => c.name === name)!.run(ctx); + +describe('the built-in session commands', () => { + it('undo takes back the last turn not yet undone', async () => { + const { ctx, session, lines } = context(); + await run('undo', ctx); + expect(session.revert).toHaveBeenCalledWith({ sessionID: 'ses_1', directory: '/w', messageID: 'm3' }); + expect(lines.at(-1)).toMatch(/^Undid "now rename it" and the file changes/); + + const again = context('m3'); + await run('undo', again.ctx); + expect(again.session.revert).toHaveBeenCalledWith({ sessionID: 'ses_1', directory: '/w', messageID: 'm1' }); + + const none = context('m1'); + await run('undo', none.ctx); + expect(none.session.revert).not.toHaveBeenCalled(); + expect(none.lines).toEqual(['Nothing to undo.']); + }); + + it('redo brings undone turns back, and says so when there are none', async () => { + const undone = context('m3'); + await run('redo', undone.ctx); + expect(undone.session.unrevert).toHaveBeenCalled(); + const none = context(); + await run('redo', none.ctx); + expect(none.session.unrevert).not.toHaveBeenCalled(); + expect(none.lines).toEqual(['Nothing to redo.']); + }); + + it("compact summarizes on the session's model; share names the link", async () => { + const { ctx, session, lines } = context(); + await run('compact', ctx); + expect(session.summarize).toHaveBeenCalledWith({ sessionID: 'ses_1', directory: '/w', providerID: 'ccr', modelID: 'm' }); + await run('share', ctx); + expect(lines.at(-1)).toContain('https://opncd.ai/s/abc'); + }); + + it('a failed call is an error naming what failed', async () => { + const { ctx, session } = context(); + session.revert.mockResolvedValue({ error: { data: { message: 'session is busy' } } }); + await expect(run('undo', ctx)).rejects.toThrow('OpenCode could not undo the last turn: {"message":"session is busy"}'); + }); + + it("gives way to a command of the server's with the same name", () => { + expect(sessionCommand('undo', new Set())?.name).toBe('undo'); + expect(sessionCommand('undo', new Set(['undo']))).toBeUndefined(); + expect(sessionSlashCommands(new Set(['share'])).map((c) => c.name)).toEqual(['compact', 'undo', 'redo', 'unshare']); + }); +}); diff --git a/packages/agent-host/src/drivers/opencode/adapter.ts b/packages/agent-host/src/drivers/opencode/adapter.ts index b7b086d8..76258fb9 100644 --- a/packages/agent-host/src/drivers/opencode/adapter.ts +++ b/packages/agent-host/src/drivers/opencode/adapter.ts @@ -60,6 +60,12 @@ export interface OpenCodeError { content: string; } +/** A line about the session, not from the model (a command's outcome). */ +export interface OpenCodeStatus { + type: 'status'; + text: string; +} + /** The session to resume no longer exists server-side and a fresh one was * created instead — the model does not remember earlier turns, which the * user must not learn only through a silent id change. */ @@ -102,6 +108,7 @@ export type OpenCodeEvent = | OpenCodeIdle | OpenCodePart | OpenCodeError + | OpenCodeStatus | OpenCodeResumeLost | OpenCodeDiff | OpenCodeQuestion @@ -122,6 +129,8 @@ export function opencodeEventToEntries(event: OpenCodeEvent, ctx: TranslateConte return parsePart(event, ctx); case 'error': return [{ entryType: 'error', text: event.content, timestamp: ts }]; + case 'status': + return [{ entryType: 'status', text: event.text, timestamp: ts }]; case 'resume-lost': return [{ entryType: 'notice', diff --git a/packages/agent-host/src/drivers/opencode/catalog.ts b/packages/agent-host/src/drivers/opencode/catalog.ts new file mode 100644 index 00000000..a0e64cdd --- /dev/null +++ b/packages/agent-host/src/drivers/opencode/catalog.ts @@ -0,0 +1,123 @@ +/** + * OpenCode's catalog of providers and their models — models.dev, which + * OpenCode ships and keeps current, plus what the operator configured — as + * its server lists it (`GET /provider`). A provider profile learns two + * things from it: + * + * - whether its endpoint is a provider OpenCode already knows (DeepSeek, + * OpenRouter, Moonshot…), by the base URL the catalog records for it; + * such a profile signs in to that provider, the way `/connect` would, + * and gets the catalog's models, prices and limits whole; + * - what its models are, when the endpoint is a gateway: a routed + * `OpenCode Go/deepseek-v4.1-flash` is the catalog's OpenCode Go model, + * whose context and output limits, reasoning (and with it OpenCode's + * reasoning levels), tool calls and image input the gateway's model list + * never says. Prices are left out: a gateway's are its own (a + * subscription, a local model, a markup), not the upstream's list price. + */ +import type { Model, Provider } from '@opencode-ai/sdk/v2/client'; +import type { ProviderModel } from '../../sdk/types'; + +/** `name` as an OpenCode provider id: lower case, each run of anything but + * letters and digits one `-` (a `/` would split its model ids). */ +export function providerIdOf(name: string): string { + return name + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export interface Catalog { + /** Every provider OpenCode knows, by id. */ + providers: ReadonlyMap; + /** Those it can already use: configured, signed in to, or given a key in + * its environment. */ + connected: ReadonlySet; +} + +export const EMPTY_CATALOG: Catalog = { providers: new Map(), connected: new Set() }; + +/** A base URL as endpoints are compared: lower case, without a trailing + * slash or `/v1` (both spellings are common for one endpoint). */ +export function endpointKey(url: string): string { + return url.trim().toLowerCase().replace(/\/+$/, '').replace(/\/v1$/, ''); +} + +/** The base URLs a catalog provider is served at. */ +function providerEndpoints(provider: Provider): string[] { + const urls = Object.values(provider.models).map((m) => m.api?.url); + const configured = (provider.options as { baseURL?: unknown } | undefined)?.baseURL; + if (typeof configured === 'string') urls.push(configured); + return [...new Set(urls.filter((u): u is string => typeof u === 'string' && u.trim() !== '').map(endpointKey))]; +} + +/** The catalog provider served at `baseUrl`, leaving out the `skip` ids + * (the providers profiles themselves became). */ +export function knownProviderAt(catalog: Catalog, baseUrl: string, skip: ReadonlySet): Provider | undefined { + const key = endpointKey(baseUrl); + for (const provider of catalog.providers.values()) { + if (!skip.has(provider.id) && providerEndpoints(provider).includes(key)) return provider; + } + return undefined; +} + +/** The catalog's record of a profile model: under the provider a gateway + * routes it to when the endpoint names one (by name or id), else under the + * first provider that lists its id. */ +export function catalogModel(catalog: Catalog, model: ProviderModel, skip: ReadonlySet): Model | undefined { + const upstream = model.provider?.trim(); + if (upstream) { + const bare = model.id.startsWith(`${upstream}/`) ? model.id.slice(upstream.length + 1) : model.id; + const wanted = upstream.toLowerCase(); + const slug = providerIdOf(upstream); + for (const provider of catalog.providers.values()) { + if (skip.has(provider.id)) continue; + if (provider.name.toLowerCase() === wanted || provider.id === slug) { + const found = provider.models[bare]; + if (found) return found; + } + } + } + for (const provider of catalog.providers.values()) { + if (skip.has(provider.id)) continue; + const found = provider.models[model.id]; + if (found) return found; + } + return undefined; +} + +type Modality = 'text' | 'audio' | 'image' | 'video' | 'pdf'; + +function modalities(flags: Record): Modality[] { + return (Object.keys(flags) as Modality[]).filter((k) => flags[k]); +} + +/** + * A profile model as OpenCode's config holds it: named without the upstream + * provider its group already shows, with what the catalog knows of it + * (`known`) — never its prices. The endpoint's own context window wins over + * the catalog's; without any, the limits stay unset, OpenCode's "unknown". + */ +export function profileModelConfig(model: ProviderModel, known: Model | undefined): Record { + const name = + model.label ?? + (model.provider && model.id.startsWith(`${model.provider}/`) ? model.id.slice(model.provider.length + 1) : undefined) ?? + known?.name; + const context = model.contextWindow || known?.limit.context || 0; + return { + ...(name ? { name } : {}), + ...(context > 0 ? { limit: { context, output: known?.limit.output ?? 0 } } : {}), + ...(known + ? { + ...(known.family ? { family: known.family } : {}), + reasoning: known.capabilities.reasoning, + tool_call: known.capabilities.toolcall, + temperature: known.capabilities.temperature, + attachment: known.capabilities.attachment, + modalities: { input: modalities(known.capabilities.input), output: modalities(known.capabilities.output) }, + } + : {}), + }; +} diff --git a/packages/agent-host/src/drivers/opencode/driver.ts b/packages/agent-host/src/drivers/opencode/driver.ts index 6be47498..12531a25 100644 --- a/packages/agent-host/src/drivers/opencode/driver.ts +++ b/packages/agent-host/src/drivers/opencode/driver.ts @@ -42,6 +42,7 @@ import { newTranslateContext } from '../../sdk/transcript'; import type { AgentInfo, ModelEntry, + OptionChoice, PermissionOption, ProviderBinding, QuestionSpec, @@ -52,10 +53,12 @@ import type { Subagent, UsageData, } from '../../sdk/types'; +import { SESSION_COMMANDS, sessionCommand, sessionSlashCommands } from './sessionCommands'; import { opencodeEventToEntries, toolCallDiffs, type OpenCodeEvent } from './adapter'; import { OpenCodeMcp, openCodeSessionMcp, toggleOpenCodeMcp } from './mcp'; import { OpenCodePlugins } from './plugins'; -import { admitProfiles, profileModelGroup, profileProviderId, providersFingerprint, serverSetup } from './providers'; +import { type Catalog, EMPTY_CATALOG } from './catalog'; +import { NOTHING_SERVED, type Placement, placeProfiles, profileModelGroup, providersFingerprint, type Served, servedBy, serverSetup } from './providers'; import { resolveOpenCodePath, startOpenCodeServer, @@ -203,6 +206,30 @@ export function pickDefaultModel( /** Why OpenCode cannot run `model`: none of its providers offers it. When * the model list could not be fetched, nothing is refused. */ +/** The variant OpenCode reads as "none": the model's own settings. */ +const DEFAULT_VARIANT = 'default'; + +/** How a variant reads in the effort picker. */ +const VARIANT_LABELS: Record = { xhigh: 'Extra high', none: 'None' }; + +/** A model's reasoning levels, from its OpenCode variants (`low`, `high`, + * `max`…, set by OpenCode from what the model is): its default first. A + * model without variants has none. */ +export function reasoningLevels(variants: Record | undefined): OptionChoice[] { + const ids = Object.keys(variants ?? {}).filter((id) => id !== DEFAULT_VARIANT); + if (ids.length === 0) return []; + const label = (id: string) => VARIANT_LABELS[id] ?? id.charAt(0).toUpperCase() + id.slice(1); + return [{ id: DEFAULT_VARIANT, label: 'Default' }, ...ids.map((id) => ({ id, label: label(id) }))]; +} + +/** The name OpenCode gives a session it has not titled yet + * ("New session - "; "Child session - …" for a sub-agent's). */ +const PLACEHOLDER_TITLE = /^(New|Child) session - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +export function isPlaceholderTitle(title: string): boolean { + return PLACEHOLDER_TITLE.test(title); +} + export function unsupportedModelReason(model: string, models: ModelEntry[]): string | undefined { if (models.length === 0 || models.some((m) => m.id === model)) return undefined; return `OpenCode does not offer the model '${model}' — none of its configured providers serves it; choose one from its model list.`; @@ -295,7 +322,12 @@ export class OpenCodeSession implements DriverSession { private readonly translate = newTranslateContext(); private ended = false; private mode: string; + /** The title last reported for the session. */ + private title: string | undefined; private model?: { providerID: string; modelID: string }; + /** The reasoning level ("variant") prompts ask of the model; none = the + * model's default. */ + private variant: string | undefined; /** messageID -> role, seeded from message.updated events, so a later * message.part.updated for the same messageID can be tagged — Part itself @@ -361,6 +393,9 @@ export class OpenCodeSession implements DriverSession { this.mode = params.mode ?? DEFAULT_MODE; this.catalog = options.catalog ?? (async () => ({ models: [] })); this.model = splitModelId(params.model ?? undefined); + // A level the model lacks is ignored by OpenCode: the session starts on + // the model's default rather than failing. + this.variant = params.effort && params.effort !== DEFAULT_VARIANT ? params.effort : undefined; this.ready = this.init(clientPromise, params); // init() reports its own failure as `ended`; nothing else awaits this // rejection except prompt/interrupt, which catch it themselves. @@ -411,6 +446,7 @@ export class OpenCodeSession implements DriverSession { if (resumeLost) this.deliver({ type: 'resume-lost' }); if (!this.ended) { this.ctx.emit({ type: 'info', nativeSessionId: session.id, ...(model ? { model } : {}), mode: this.mode }); + this.reportTitle(session.title); this.deliver({ type: 'started', ...(model ? { model } : {}) }); this.ctx.emit({ type: 'ready' }); } @@ -447,13 +483,14 @@ export class OpenCodeSession implements DriverSession { `[opencode] resume ${params.resume} not found server-side ` + `(${error ? JSON.stringify(error) : 'no session returned'}) — starting a fresh session instead`, ); - return { session: await this.createSessionRemote(client, params), resumeLost: true }; + return { session: await this.createSessionRemote(client), resumeLost: true }; } - return { session: await this.createSessionRemote(client, params), resumeLost: false }; + return { session: await this.createSessionRemote(client), resumeLost: false }; } - private async createSessionRemote(client: OpencodeClient, params: StartSession): Promise { - const { data, error } = await client.session.create({ directory: this.cwd, title: params.sessionId }); + /** Created untitled, so OpenCode names it after its first message. */ + private async createSessionRemote(client: OpencodeClient): Promise { + const { data, error } = await client.session.create({ directory: this.cwd }); if (error || !data) { throw new Error(`OpenCode session.create failed: ${JSON.stringify(error ?? 'no session returned')}`); } @@ -521,6 +558,10 @@ export class OpenCodeSession implements DriverSession { if (!this.ended) this.ctx.emit({ type: 'turn', state: status === 'busy' ? 'running' : 'idle' }); break; } + case 'session.updated': { + if (event.properties.sessionID === sessionId) this.reportTitle(event.properties.info.title); + break; + } case 'session.diff': { if (event.properties.sessionID !== sessionId) continue; const files = this.changedDiffs(event.properties.diff); @@ -841,16 +882,23 @@ export class OpenCodeSession implements DriverSession { const command = parseSlashCommand(text); this.ready .then(async ({ client, session }) => { - if (command && (await this.knownCommands(client)).has(command.name)) { + const listed = command ? await this.knownCommands(client) : new Set(); + if (command && listed.has(command.name)) { this.runCommand(client, session.id, command.name, command.args); return; } + const builtin = command && sessionCommand(command.name, listed); + if (builtin) { + this.runSessionCommand(client, session.id, builtin); + return; + } const { error } = await client.session.promptAsync({ sessionID: session.id, directory: this.cwd, parts: [{ type: 'text', text }], ...(this.model ? { model: this.model } : {}), ...this.agentField(), + ...this.variantField(), }); if (error) this.deliver({ type: 'error', content: `OpenCode prompt failed: ${JSON.stringify(error)}` }); }) @@ -859,6 +907,28 @@ export class OpenCodeSession implements DriverSession { }); } + /** One of the commands OpenCode's terminal runs itself; its outcome is a + * status line, its failure an error entry. */ + private runSessionCommand(client: OpencodeClient, sessionID: string, command: (typeof SESSION_COMMANDS)[number]): void { + command + .run({ + client, + sessionID, + directory: this.cwd, + ...(this.model ? { model: this.model } : {}), + status: (text) => this.deliver({ type: 'status', text }), + }) + .catch((err) => this.deliver({ type: 'error', content: err instanceof Error ? err.message : String(err) })); + } + + /** The title OpenCode gave the session, once it has one of its own + * (not the timestamp a new session starts with) — each new one once. */ + private reportTitle(title: string | undefined): void { + if (!title || isPlaceholderTitle(title) || title === this.title || this.ended) return; + this.title = title; + this.ctx.emit({ type: 'info', title }); + } + /** The OpenCode agent a prompt names: the plan agent in the plan mode, * none (OpenCode's default) otherwise. */ private agentField(): { agent?: string } { @@ -872,7 +942,7 @@ export class OpenCodeSession implements DriverSession { private runCommand(client: OpencodeClient, sessionID: string, name: string, args: string): void { const model = this.model ? `${this.model.providerID}/${this.model.modelID}` : undefined; client.session - .command({ sessionID, directory: this.cwd, command: name, arguments: args, ...(model ? { model } : {}), ...this.agentField() }) + .command({ sessionID, directory: this.cwd, command: name, arguments: args, ...(model ? { model } : {}), ...this.agentField(), ...this.variantField() }) .then(({ error }) => { if (error) this.deliver({ type: 'error', content: `OpenCode /${name} failed: ${JSON.stringify(error)}` }); }) @@ -897,9 +967,11 @@ export class OpenCodeSession implements DriverSession { return this.commandNames; } + /** The server's commands, then the ones the driver runs itself. */ async listCommands(): Promise { const { client } = await this.ready; - return toSlashCommands(await this.fetchCommands(client)); + const commands = await this.fetchCommands(client); + return [...toSlashCommands(commands), ...sessionSlashCommands(new Set(commands.map((c) => c.name)))]; } async mcpStatus(): Promise { @@ -922,16 +994,33 @@ export class OpenCodeSession implements DriverSession { // Model selection is per prompt in OpenCode; the next prompt uses it. const split = splitModelId(value); if (!split) throw new Error(`'${value}' is not an OpenCode provider/model id`); - const refused = unsupportedModelReason(value, (await this.catalog()).models); + const models = (await this.catalog()).models; + const refused = unsupportedModelReason(value, models); if (refused) throw new Error(refused); this.model = split; + // A level the new model lacks would be ignored; drop it. + if (this.variant && !models.find((m) => m.id === value)?.efforts?.some((e) => e.id === this.variant)) this.variant = undefined; + return; + } + case 'effort': { + // Levels are the model's own (its OpenCode variants). + const { models, defaultModel } = await this.catalog(); + const id = this.model ? `${this.model.providerID}/${this.model.modelID}` : defaultModel; + const levels = models.find((m) => m.id === id)?.efforts ?? []; + if (!levels.some((e) => e.id === value)) { + throw new Error(levels.length > 0 ? `'${value}' is not a reasoning level of ${id}` : `${id ?? 'This model'} has no reasoning levels`); + } + this.variant = value === DEFAULT_VARIANT ? undefined : value; return; } - case 'effort': - throw new Error('OpenCode has no effort levels'); } } + /** The reasoning level a prompt or command asks for, if any. */ + private variantField(): { variant?: string } { + return this.variant ? { variant: this.variant } : {}; + } + async interrupt(): Promise { try { const { client, session } = await this.ready; @@ -953,8 +1042,14 @@ export class OpenCodeSession implements DriverSession { if (error) throw new Error(`OpenCode could not stop the task: ${JSON.stringify(error)}`); } + /** What the session has cost so far, as OpenCode prices its models (no + * subscription windows: OpenCode has none to report). A free or + * unpriced model's session reports no cost. */ async getUsage(): Promise { - return null; + const { client, session } = await this.ready; + const { data, error } = await client.session.get({ sessionID: session.id, directory: this.cwd }); + if (error || !data) return null; + return { available: true, windows: [], ...(data.cost ? { sessionCostUsd: data.cost } : {}), fetchedAt: new Date().toISOString() }; } async end(): Promise { @@ -1012,12 +1107,14 @@ export class OpenCodeDriver implements Driver { * session. */ private installs = false; private stopped = false; - /** The provider profiles the server should have, and those the running - * one was started with. */ - private providers: ProviderBinding[] = []; + /** Where the provider profiles go among the server's providers, and the + * catalog that placed them (it fills in their models). */ + private placed: Placement[] = []; + private catalog: Catalog = EMPTY_CATALOG; + /** The placements the running server was started with: their + * fingerprint, and the providers they made. */ private served = providersFingerprint([]); - /** The provider ids the running server has from profiles. */ - private servedIds = new Set(); + private serving: Served = NOTHING_SERVED; /** Provider changes are applied one at a time, in order. */ private applying: Promise = Promise.resolve(); readonly plugins: PluginManager = new OpenCodePlugins(() => this.client()); @@ -1079,8 +1176,8 @@ export class OpenCodeDriver implements Driver { /** Start `opencode serve` with the provider profiles it should have, and * connect to it. */ private async startServer(bin: string): Promise { - const profiles = this.providers; - const setup = serverSetup(profiles, process.env); + const placed = this.placed; + const setup = serverSetup(placed, this.catalog, process.env); const start = this.options.startServer ?? startOpenCodeServer; let server: OpenCodeServerHandle; try { @@ -1097,9 +1194,9 @@ export class OpenCodeDriver implements Driver { throw new Error('the agent host is shutting down'); } this.server = server; - this.served = providersFingerprint(profiles); - this.servedIds = new Set(profiles.map(profileProviderId)); - const added = profiles.length > 0 ? `, with ${profiles.length} provider profile(s)` : ''; + this.served = providersFingerprint(placed); + this.serving = servedBy(placed); + const added = placed.length > 0 ? `, with ${placed.length} provider profile(s)` : ''; this.options.log(`[opencode] started ${server.url} (pid ${server.pid ?? '?'})${added}`); return this.connect({ baseUrl: server.url, headers: setup.headers }); } @@ -1125,8 +1222,9 @@ export class OpenCodeDriver implements Driver { ); } - /** The provider profiles to add to OpenCode's own providers; those whose - * name one of its providers already has are left out. The server reads + /** The provider profiles to add to OpenCode's own providers, placed by + * its catalog (providers.ts); one that would shadow a provider OpenCode + * has is left out, with the reason. The server reads * its config only when it starts, so a changed list restarts it: * sessions on the old one end with an error and the bridge resumes them * on the new one. */ @@ -1144,13 +1242,15 @@ export class OpenCodeDriver implements Driver { private async applyProviders(providers: ProviderBinding[]): Promise { // A server still being installed or started is waited for: it may have - // read an older list, and it says which names are taken. + // read an older list, and its catalog places the profiles. await this.clientPromise?.catch(() => {}); // A profile the bridge would not let a session use is not added either. const usable = providers.filter((p) => isValidProviderBaseUrl(p.baseUrl) && p.authToken !== '' && p.models.length > 0); - const { admitted, refused } = admitProfiles(usable, await this.ownProviderIds()); - this.providers = admitted; - if (this.stopped || !this.server || !this.bin || providersFingerprint(this.providers) === this.served) return refused; + const catalog = await this.readCatalog(); + const { placed, refused } = placeProfiles(usable, catalog, this.serving); + this.placed = placed; + this.catalog = catalog; + if (this.stopped || !this.server || !this.bin || providersFingerprint(placed) === this.served) return refused; this.options.log('[opencode] restarting the server: its provider profiles changed'); const old = this.server; this.server = null; @@ -1159,19 +1259,19 @@ export class OpenCodeDriver implements Driver { return refused; } - /** The provider ids OpenCode has of its own — built in, configured by - * the operator, signed in to — without the profiles this driver added. - * Empty when the server cannot say (nothing is refused then). */ - private async ownProviderIds(): Promise> { + /** The running server's catalog (catalog.ts). Empty when it cannot say: + * every profile is then a provider of its own, nothing refused or + * filled in. */ + private async readCatalog(): Promise { try { const client = await this.clientPromise; - if (!client) return new Set(); + if (!client) return EMPTY_CATALOG; const { data, error } = await client.provider.list(); if (error || !data) throw new Error(JSON.stringify(error ?? 'no provider list')); - return new Set(data.all.map((p) => p.id).filter((id) => !this.servedIds.has(id))); + return { providers: new Map(data.all.map((p) => [p.id, p])), connected: new Set(data.connected) }; } catch (err) { - this.options.log(`[opencode] could not list its providers, so profile names are not checked: ${err instanceof Error ? err.message : String(err)}`); - return new Set(); + this.options.log(`[opencode] could not read its provider catalog, so profiles are not checked against it: ${err instanceof Error ? err.message : String(err)}`); + return EMPTY_CATALOG; } } @@ -1182,11 +1282,11 @@ export class OpenCodeDriver implements Driver { modes: OPENCODE_MODES, efforts: [], defaultMode: DEFAULT_MODE, - // No subscription usage. Provider profiles add models to the server - // this driver starts; sessions are never bound to one. + // Usage is the session's cost. Provider profiles add models to the + // server this driver starts; sessions are never bound to one. supports: { models: true, - usage: false, + usage: true, providers: false, providerModels: this.manages, gsd: true, @@ -1237,14 +1337,15 @@ export class OpenCodeDriver implements Driver { if (error || !data) return { models: [] }; const models: ModelEntry[] = []; const contextLimits: Record = {}; - const profiles = new Map(this.providers.map((p) => [profileProviderId(p), p])); + const profiles = new Map(this.placed.map((p) => [p.providerId, p.profile])); for (const provider of data.providers) { const profile = profiles.get(provider.id); for (const model of Object.values(provider.models)) { const id = `${provider.id}/${model.id}`; const name = provider.name || provider.id; const group = profile ? profileModelGroup(name, profile.models.find((m) => m.id === model.id)) : name; - models.push({ id, label: model.name, provider: group }); + const efforts = reasoningLevels(model.variants); + models.push({ id, label: model.name, provider: group, ...(efforts.length > 0 ? { efforts } : {}) }); const window = model.limit?.context ?? 0; if (window > 0) contextLimits[id] = window; } diff --git a/packages/agent-host/src/drivers/opencode/providers.ts b/packages/agent-host/src/drivers/opencode/providers.ts index 82a3214a..d9498106 100644 --- a/packages/agent-host/src/drivers/opencode/providers.ts +++ b/packages/agent-host/src/drivers/opencode/providers.ts @@ -2,72 +2,111 @@ * Provider profiles as OpenCode providers. * * A profile adds a provider to OpenCode — beside OpenCode Zen and whatever - * the operator configured, which all stay: an OpenAI-compatible endpoint, - * its token and its models. OpenCode takes a custom provider only from its - * config, so the profiles reach the server this driver starts as config - * handed over in its environment, on top of the operator's files (which are - * never written): + * the operator configured, which all stay. OpenCode takes a provider only + * from its config, so the profiles reach the server this driver starts as + * config handed over in its environment, on top of the operator's files + * (which are never written). Each profile is placed one of two ways, by + * what OpenCode's catalog knows (catalog.ts): * - * - each profile is a provider named as the user named the profile, its - * id that name in lower case (`CCR` is `ccr`, its models `ccr/`); - * a profile whose id one of OpenCode's own providers already has, or an - * earlier profile, is left out with the reason rather than shadow it; - * - each token sits in the server's environment only, referenced from the - * config, never in a file; - * - the server answers only with a password made for it, since its API - * reads its config — the tokens included — back to anyone on loopback - * who asks. + * - an endpoint the catalog knows (DeepSeek's API, OpenRouter's…) signs in + * to that provider, as `/connect` would: the catalog's models, prices + * and limits, under the name the user gave the profile, plus any model + * the endpoint lists that the catalog does not; + * - any other endpoint (a gateway, a local server) is an OpenAI-compatible + * provider of its own, its id the profile's name in lower case (`CCR` is + * `ccr`, its models `ccr/`), its models what the endpoint listed, + * each filled in from the catalog where it knows the model. + * + * A profile is left out, with the reason, rather than shadow a provider + * OpenCode already has: one it is already signed in to, or one whose id the + * profile's name would take, or another profile's. Each token sits in the + * server's environment only, referenced from the config; the server answers + * only with a password made for it, since its API reads its config — the + * tokens included — back to anyone on loopback who asks. * * The server reads its config when it starts, so a changed profile list * means a new server (see the driver). */ import { createHash, randomBytes } from 'node:crypto'; +import type { Provider } from '@opencode-ai/sdk/v2/client'; import { providerApiRoot } from '../../sdk/providerModels'; import type { ProviderBinding, ProviderModel, RefusedProvider } from '../../sdk/types'; +import { type Catalog, catalogModel, EMPTY_CATALOG, knownProviderAt, profileModelConfig, providerIdOf } from './catalog'; /** The user name OpenCode's server expects with its password. */ const SERVER_USERNAME = 'opencode'; -/** `name` as an OpenCode provider id: lower case, each run of anything but - * letters and digits one `-` (a `/` would split its model ids). */ -function providerIdOf(name: string): string { - return name - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); +/** Where a profile sits among OpenCode's providers. */ +export interface Placement { + profile: ProviderBinding; + /** Its OpenCode provider id: the catalog's for a provider OpenCode knows, + * else its name's. */ + providerId: string; + /** The catalog provider it signs in to, when OpenCode knows its endpoint. */ + known?: Provider; +} + +/** What profiles already are in a running server, so they are not taken + * for OpenCode's own providers. */ +export interface Served { + /** Every provider id profiles took. */ + ids: ReadonlySet; + /** Those that are a profile's own provider, not one of the catalog's. */ + own: ReadonlySet; } -/** The OpenCode provider id of a profile: its name's, or its id's when the - * name has no letter or digit. */ +export const NOTHING_SERVED: Served = { ids: new Set(), own: new Set() }; + +/** The provider id a profile of its own takes: its name's, or its id's + * when the name has no letter or digit. */ export function profileProviderId(profile: ProviderBinding): string { return providerIdOf(profile.label) || providerIdOf(profile.id) || 'provider'; } -/** Which of `profiles` OpenCode can take, in order: one whose provider id - * is `taken` by a provider OpenCode already has, or by an earlier profile, - * is refused with the reason. */ -export function admitProfiles( +/** + * Where each of `profiles` goes, in order, against what OpenCode has + * (`catalog`) and what earlier profiles took (`served`, not OpenCode's own); + * a profile that would shadow a provider is refused with the reason. + */ +export function placeProfiles( profiles: ProviderBinding[], - taken: ReadonlySet, -): { admitted: ProviderBinding[]; refused: RefusedProvider[] } { - const admitted: ProviderBinding[] = []; + catalog: Catalog, + served: Served = NOTHING_SERVED, +): { placed: Placement[]; refused: RefusedProvider[] } { + const placed: Placement[] = []; const refused: RefusedProvider[] = []; - const used = new Map(); + const taken = new Map(); for (const profile of profiles) { - const id = profileProviderId(profile); - const earlier = used.get(id); - if (taken.has(id)) { - refused.push({ id: profile.id, reason: `OpenCode already has a provider called '${id}'. Give this profile another name.` }); - } else if (earlier) { - refused.push({ id: profile.id, reason: `The provider profile '${earlier.label}' already goes by '${id}' in OpenCode. Give this one another name.` }); - } else { - used.set(id, profile); - admitted.push(profile); + const known = knownProviderAt(catalog, profile.baseUrl, served.own); + const providerId = known?.id ?? profileProviderId(profile); + const earlier = taken.get(providerId); + const theirs = !served.ids.has(providerId); + let reason: string | undefined; + if (earlier) { + reason = known + ? `The provider profile '${earlier.label}' already signs OpenCode in to ${known.name}.` + : `The provider profile '${earlier.label}' already goes by '${providerId}' in OpenCode. Give this one another name.`; + } else if (known && theirs && catalog.connected.has(providerId)) { + reason = `OpenCode already uses ${known.name} with a key of its own; this profile would replace it.`; + } else if (!known && theirs && catalog.providers.has(providerId)) { + reason = `OpenCode already has a provider called '${providerId}'. Give this profile another name.`; + } + if (reason) { + refused.push({ id: profile.id, reason }); + continue; } + taken.set(providerId, profile); + placed.push({ profile, providerId, ...(known ? { known } : {}) }); } - return { admitted, refused }; + return { placed, refused }; +} + +/** What `placed` makes of a running server's providers. */ +export function servedBy(placed: Placement[]): Served { + return { + ids: new Set(placed.map((p) => p.providerId)), + own: new Set(placed.filter((p) => !p.known).map((p) => p.providerId)), + }; } /** The variable profile `index`'s token reaches the server in. */ @@ -75,52 +114,56 @@ function tokenVariable(index: number): string { return `CODEDECK_PROVIDER_KEY_${index}`; } -/** The provider entries for `profiles`, on top of `base` (an operator's own +/** One placed profile's provider entry. */ +function providerEntry(placement: Placement, index: number, catalog: Catalog, served: Served): Record { + const { profile, known } = placement; + const apiKey = `{env:${tokenVariable(index)}}`; + if (known) { + // The catalog's models stand; the endpoint's others join them. + const extra = profile.models.filter((m) => !known.models[m.id]); + return { + name: profile.label || known.name, + options: { apiKey }, + ...(extra.length > 0 ? { models: Object.fromEntries(extra.map((m) => [m.id, profileModelConfig(m, undefined)])) } : {}), + }; + } + return { + npm: '@ai-sdk/openai-compatible', + name: profile.label || profile.id, + options: { baseURL: providerApiRoot(profile.baseUrl), apiKey }, + models: Object.fromEntries(profile.models.map((m) => [m.id, profileModelConfig(m, catalogModel(catalog, m, served.own))])), + }; +} + +/** The provider entries for `placed`, on top of `base` (an operator's own * `OPENCODE_CONFIG_CONTENT`, whose providers stay). */ -export function providersConfig(profiles: ProviderBinding[], base: Record = {}): Record { +export function providersConfig( + placed: Placement[], + catalog: Catalog = EMPTY_CATALOG, + base: Record = {}, +): Record { const own = (base.provider ?? {}) as Record; - const added = Object.fromEntries( - profiles.map((profile, index) => [ - profileProviderId(profile), - { - npm: '@ai-sdk/openai-compatible', - name: profile.label || profile.id, - options: { baseURL: providerApiRoot(profile.baseUrl), apiKey: `{env:${tokenVariable(index)}}` }, - models: Object.fromEntries(profile.models.map((m) => [m.id, modelConfig(m)])), - }, - ]), - ); + const served = servedBy(placed); + const added = Object.fromEntries(placed.map((p, index) => [p.providerId, providerEntry(p, index, catalog, served)])); return { ...base, provider: { ...own, ...added } }; } -/** A profile model as OpenCode's config holds it: named without the - * upstream provider its group already shows. A known context window - * lets OpenCode compact before the model overflows; the output size is - * never listed by endpoints, so it stays 0, OpenCode's own "unknown". */ -function modelConfig(model: ProviderModel): Record { - const name = model.label ?? (model.provider && model.id.startsWith(`${model.provider}/`) ? model.id.slice(model.provider.length + 1) : undefined); - return { - ...(name ? { name } : {}), - ...(model.contextWindow ? { limit: { context: model.contextWindow, output: 0 } } : {}), - }; -} - /** The group a profile model is listed under: the profile, and the * provider a gateway routes the model to when the endpoint names one. */ export function profileModelGroup(profile: string, model: ProviderModel | undefined): string { return model?.provider ? `${profile} · ${model.provider}` : profile; } -/** What the server is started with to serve `profiles`, and how its client - * signs in. */ +/** What the server is started with to serve the profiles, and how its + * client signs in. */ export interface ServerSetup { env: Record; headers: Record; } -/** The environment and credentials of a server for `profiles` — and, for +/** The environment and credentials of a server for `placed` — and, for * every model, web search. `baseEnv` is the host's own environment. */ -export function serverSetup(profiles: ProviderBinding[], baseEnv: NodeJS.ProcessEnv): ServerSetup { +export function serverSetup(placed: Placement[], catalog: Catalog, baseEnv: NodeJS.ProcessEnv): ServerSetup { const password = randomBytes(24).toString('hex'); const env: Record = { OPENCODE_SERVER_USERNAME: SERVER_USERNAME, @@ -130,9 +173,9 @@ export function serverSetup(profiles: ProviderBinding[], baseEnv: NodeJS.Process // still behind the `websearch` permission). OPENCODE_ENABLE_EXA: '1', }; - if (profiles.length > 0) { - env.OPENCODE_CONFIG_CONTENT = JSON.stringify(providersConfig(profiles, operatorConfig(baseEnv))); - profiles.forEach((profile, index) => { + if (placed.length > 0) { + env.OPENCODE_CONFIG_CONTENT = JSON.stringify(providersConfig(placed, catalog, operatorConfig(baseEnv))); + placed.forEach(({ profile }, index) => { env[tokenVariable(index)] = profile.authToken; }); } @@ -152,9 +195,9 @@ function operatorConfig(baseEnv: NodeJS.ProcessEnv): Record { } } -/** What makes two profile lists need different servers: everything their +/** What makes two placements need different servers: everything their * config is built from. Hashed, so the tokens are not kept around in it. */ -export function providersFingerprint(profiles: ProviderBinding[]): string { - const parts = profiles.map((p) => [p.id, p.label, p.baseUrl, p.authToken, p.models]); +export function providersFingerprint(placed: Placement[]): string { + const parts = placed.map(({ profile: p, providerId }) => [providerId, p.id, p.label, p.baseUrl, p.authToken, p.models]); return createHash('sha256').update(JSON.stringify(parts)).digest('hex'); } diff --git a/packages/agent-host/src/drivers/opencode/sessionCommands.ts b/packages/agent-host/src/drivers/opencode/sessionCommands.ts new file mode 100644 index 00000000..acc598d5 --- /dev/null +++ b/packages/agent-host/src/drivers/opencode/sessionCommands.ts @@ -0,0 +1,134 @@ +/** + * Commands OpenCode's terminal runs itself and its server does not list: + * compacting the conversation, undoing and redoing turns, sharing it. Each + * runs through the server's API, and its outcome is a status line in the + * transcript (the transcript keeps what an undo took back; the line says + * so). A command the server lists under the same name — a project's own — + * is the one that runs. + */ +import type { OpencodeClient } from '@opencode-ai/sdk/v2/client'; +import { slashCommand } from '../../sdk/commands'; +import type { SlashCommand } from '../../sdk/types'; + +/** What a command works on and how it reports back. */ +export interface SessionCommandContext { + client: OpencodeClient; + sessionID: string; + directory: string; + /** The model the session's prompts name, if one was chosen. */ + model?: { providerID: string; modelID: string }; + status(text: string): void; +} + +interface SessionCommand { + name: string; + description: string; + run(ctx: SessionCommandContext): Promise; +} + +/** A failed call's reason, for the error entry. */ +function failure(what: string, error: unknown): Error { + const detail = typeof error === 'object' && error !== null && 'data' in error ? (error as { data: unknown }).data : error; + return new Error(`OpenCode could not ${what}: ${JSON.stringify(detail)}`); +} + +/** The first line of a prompt, short enough to name it in a status line. */ +function gist(text: string): string { + const line = text.trim().split('\n')[0] ?? ''; + return line.length > 60 ? `${line.slice(0, 57)}…` : line; +} + +/** The user turns of a session, oldest first, with their text. */ +async function userTurns(ctx: SessionCommandContext): Promise> { + const { data, error } = await ctx.client.session.messages({ sessionID: ctx.sessionID, directory: ctx.directory }); + if (error || !data) throw failure('read the conversation', error); + return data + .filter((m) => m.info.role === 'user') + .map((m) => ({ + id: m.info.id, + text: m.parts.flatMap((p) => (p.type === 'text' && !p.synthetic ? [p.text] : [])).join('\n'), + })); +} + +async function revertPoint(ctx: SessionCommandContext): Promise { + const { data, error } = await ctx.client.session.get({ sessionID: ctx.sessionID, directory: ctx.directory }); + if (error || !data) throw failure('read the session', error); + return data.revert?.messageID; +} + +export const SESSION_COMMANDS: SessionCommand[] = [ + { + name: 'compact', + description: 'Summarize the conversation so far, to free up context', + async run(ctx) { + ctx.status('Compacting the conversation…'); + const { error } = await ctx.client.session.summarize({ + sessionID: ctx.sessionID, + directory: ctx.directory, + ...(ctx.model ?? {}), + }); + if (error) throw failure('compact the conversation', error); + ctx.status('Compacted: OpenCode continues from a summary of the conversation.'); + }, + }, + { + name: 'undo', + description: 'Take back the last turn and the file changes it made', + async run(ctx) { + const [turns, point] = await Promise.all([userTurns(ctx), revertPoint(ctx)]); + // Turns from the revert point on are already undone. + const live = point ? turns.slice(0, Math.max(0, turns.findIndex((t) => t.id === point))) : turns; + const last = live.at(-1); + if (!last) { + ctx.status('Nothing to undo.'); + return; + } + const { error } = await ctx.client.session.revert({ sessionID: ctx.sessionID, directory: ctx.directory, messageID: last.id }); + if (error) throw failure('undo the last turn', error); + ctx.status( + `Undid "${gist(last.text)}" and the file changes it made. OpenCode forgets it with your next message; /redo brings it back before then.`, + ); + }, + }, + { + name: 'redo', + description: 'Bring back the turns /undo took back', + async run(ctx) { + if (!(await revertPoint(ctx))) { + ctx.status('Nothing to redo.'); + return; + } + const { error } = await ctx.client.session.unrevert({ sessionID: ctx.sessionID, directory: ctx.directory }); + if (error) throw failure('redo', error); + ctx.status('Brought back the undone turns and their file changes.'); + }, + }, + { + name: 'share', + description: 'Publish the conversation at a link anyone can open', + async run(ctx) { + const { data, error } = await ctx.client.session.share({ sessionID: ctx.sessionID, directory: ctx.directory }); + if (error || !data) throw failure('share the conversation', error); + ctx.status(data.share?.url ? `Shared at ${data.share.url} — anyone with the link can read it. /unshare stops it.` : 'Shared.'); + }, + }, + { + name: 'unshare', + description: 'Stop sharing the conversation', + async run(ctx) { + const { error } = await ctx.client.session.unshare({ sessionID: ctx.sessionID, directory: ctx.directory }); + if (error) throw failure('stop sharing the conversation', error); + ctx.status('The conversation is no longer shared.'); + }, + }, +]; + +/** The built-in command named `name`, unless the server lists one so named. */ +export function sessionCommand(name: string, listed: ReadonlySet): SessionCommand | undefined { + return listed.has(name) ? undefined : SESSION_COMMANDS.find((c) => c.name === name); +} + +/** The built-ins for the command menu, beside the `listed` ones. */ +export function sessionSlashCommands(listed: ReadonlySet): SlashCommand[] { + return SESSION_COMMANDS.filter((c) => !listed.has(c.name)).map((c) => slashCommand(c.name, c.description)); +} diff --git a/packages/agent-host/src/generated/protocol.ts b/packages/agent-host/src/generated/protocol.ts index 65517ca0..663ea62d 100644 --- a/packages/agent-host/src/generated/protocol.ts +++ b/packages/agent-host/src/generated/protocol.ts @@ -981,6 +981,12 @@ export type ModelEntry_Deserialize = { * says: the same model can be offered by more than one provider. */ provider?: string | null, + /** + * The reasoning levels of this model, for an agent whose levels differ + * by model (its catalog entry lists none of its own). Empty: the + * agent's own levels, if any. + */ + efforts?: OptionChoice_Deserialize[], }; export type ModelEntry_Serialize = { @@ -991,6 +997,12 @@ export type ModelEntry_Serialize = { * says: the same model can be offered by more than one provider. */ provider?: string | null, + /** + * The reasoning levels of this model, for an agent whose levels differ + * by model (its catalog entry lists none of its own). Empty: the + * agent's own levels, if any. + */ + efforts?: OptionChoice_Serialize[], }; /** Session lifecycle notices a client shows as a marker line. */ @@ -1327,7 +1339,7 @@ export type SessionEvent = SessionEvent_Serialize | SessionEvent_Deserialize; */ export type SessionEvent_Deserialize = /** The agent came up and accepts prompts. Sent once per `start-session`. */ -({ type: "ready" }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never } | +({ type: "ready" }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never; title?: never } | /** Session facts changed. Only the fields that changed are set. */ ({ type: "info"; /** The agent's own conversation id — the `resume` target. */ @@ -1338,11 +1350,17 @@ model?: string | null; * The agent changed its own mode (e.g. it entered plan mode, or a * plan approval switched it). */ -mode?: string | null; contextWindow?: number | null; +mode?: string | null; +/** + * The agent named the session itself (OpenCode titles a session + * after its first message). It wins over the title the bridge takes + * from that message and over the topic it asks the agent for. + */ +title?: string | null; contextWindow?: number | null; /** 0–100. */ contextPercentage?: number | null }) & { entries?: never; error?: never; resumeLost?: never; state?: never } | /** Transcript entries, in order. The bridge assigns their seqs. */ -({ type: "entries"; entries: OutputEntry_Deserialize[] }) & { contextPercentage?: never; contextWindow?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never } | ({ type: "turn"; state: TurnState }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never } | +({ type: "entries"; entries: OutputEntry_Deserialize[] }) & { contextPercentage?: never; contextWindow?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never; title?: never } | ({ type: "turn"; state: TurnState }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; title?: never } | /** * The session is gone. Absent `error` = it ended normally. The host * forgets the session after sending this. @@ -1352,7 +1370,7 @@ contextPercentage?: number | null }) & { entries?: never; error?: never; resumeL * The agent could not find the `resume` conversation, so continuing * it is impossible; a new start must be fresh. */ -resumeLost?: boolean }) & { contextPercentage?: never; contextWindow?: never; entries?: never; mode?: never; model?: never; nativeSessionId?: never; state?: never }; +resumeLost?: boolean }) & { contextPercentage?: never; contextWindow?: never; entries?: never; mode?: never; model?: never; nativeSessionId?: never; state?: never; title?: never }; /** * Something a running session reports. The host translates its agent's own @@ -1360,7 +1378,7 @@ resumeLost?: boolean }) & { contextPercentage?: never; contextWindow?: never; en */ export type SessionEvent_Serialize = /** The agent came up and accepts prompts. Sent once per `start-session`. */ -({ type: "ready" }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never } | +({ type: "ready" }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never; title?: never } | /** Session facts changed. Only the fields that changed are set. */ ({ type: "info"; /** The agent's own conversation id — the `resume` target. */ @@ -1371,11 +1389,17 @@ model?: string | null; * The agent changed its own mode (e.g. it entered plan mode, or a * plan approval switched it). */ -mode?: string | null; contextWindow?: number | null; +mode?: string | null; +/** + * The agent named the session itself (OpenCode titles a session + * after its first message). It wins over the title the bridge takes + * from that message and over the topic it asks the agent for. + */ +title?: string | null; contextWindow?: number | null; /** 0–100. */ contextPercentage?: number | null }) & { entries?: never; error?: never; resumeLost?: never; state?: never } | /** Transcript entries, in order. The bridge assigns their seqs. */ -({ type: "entries"; entries: OutputEntry_Serialize[] }) & { contextPercentage?: never; contextWindow?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never } | ({ type: "turn"; state: TurnState }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never } | +({ type: "entries"; entries: OutputEntry_Serialize[] }) & { contextPercentage?: never; contextWindow?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; state?: never; title?: never } | ({ type: "turn"; state: TurnState }) & { contextPercentage?: never; contextWindow?: never; entries?: never; error?: never; mode?: never; model?: never; nativeSessionId?: never; resumeLost?: never; title?: never } | /** * The session is gone. Absent `error` = it ended normally. The host * forgets the session after sending this. @@ -1385,7 +1409,7 @@ contextPercentage?: number | null }) & { entries?: never; error?: never; resumeL * The agent could not find the `resume` conversation, so continuing * it is impossible; a new start must be fresh. */ -resumeLost?: boolean }) & { contextPercentage?: never; contextWindow?: never; entries?: never; mode?: never; model?: never; nativeSessionId?: never; state?: never }; +resumeLost?: boolean }) & { contextPercentage?: never; contextWindow?: never; entries?: never; mode?: never; model?: never; nativeSessionId?: never; state?: never; title?: never }; /** One MCP server of a running session. */ export type SessionMcpServer = SessionMcpServer_Serialize | SessionMcpServer_Deserialize;