diff --git a/desktop/src-tauri/src/commands/agent_auth.rs b/desktop/src-tauri/src/commands/agent_auth.rs index bba0f33860..549fe49343 100644 --- a/desktop/src-tauri/src/commands/agent_auth.rs +++ b/desktop/src-tauri/src/commands/agent_auth.rs @@ -708,6 +708,25 @@ mod tests { ); } + #[test] + fn opencode_terminal_meta_starts_official_login_command() { + let _guard = crate::managed_agents::lock_path_mutex(); + let method: AcpAuthMethod = serde_json::from_str( + r#"{"_meta":{"terminal-auth":{"command":"opencode","args":["auth","login"]}},"id":"opencode-login","name":"Log in with OpenCode"}"#, + ) + .unwrap(); + + assert!(uses_terminal_auth(&method).unwrap()); + let argv = adapter_terminal_argv("OpenCode", &method, "unused").unwrap(); + assert_eq!( + std::path::Path::new(&argv[0]) + .file_name() + .and_then(|name| name.to_str()), + Some("opencode") + ); + assert_eq!(&argv[1..], ["auth", "login"]); + } + #[test] fn terminal_argv_falls_back_to_adapter_command() { let _guard = crate::managed_agents::lock_path_mutex(); diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 5a26f0f645..d872c5b956 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -604,7 +604,6 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< mod tests { use super::*; use crate::managed_agents::{BackendKind, RespondTo}; - fn goose_runtime() -> &'static KnownAcpRuntime { &KnownAcpRuntime { id: "goose", @@ -624,6 +623,7 @@ mod tests { adapter_install_hint: "", skill_dir: None, supports_acp_model_switching: false, + supports_account_connection: false, model_env_var: Some("GOOSE_MODEL"), provider_env_var: Some("GOOSE_PROVIDER"), provider_locked: false, diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index cbbf4ce351..9527e969ad 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -163,7 +163,6 @@ pub async fn save_custom_harness( let default_args = crate::managed_agents::normalize_agent_args(&definition.command, definition.args.clone()); - Ok(AcpRuntimeCatalogEntry { id: definition.id, label: definition.label, @@ -184,6 +183,7 @@ pub async fn save_custom_harness( underlying_cli_path: None, node_required: false, auth_status: AuthStatus::NotApplicable, + supports_account_connection: false, login_hint: None, source: HarnessSource::Custom, // Carry definition env back so the edit form can read and preserve it. diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 4ee4ec79c3..ef44a297a7 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -46,6 +46,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { adapter_install_hint: "", skill_dir: None, supports_acp_model_switching: false, + supports_account_connection: false, model_env_var: Some("GOOSE_MODEL"), provider_env_var: Some("GOOSE_PROVIDER"), provider_locked: false, @@ -624,6 +625,7 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { adapter_install_hint: "", skill_dir: None, supports_acp_model_switching: true, + supports_account_connection: false, model_env_var: Some("BUZZ_AGENT_MODEL"), provider_env_var: Some("BUZZ_AGENT_PROVIDER"), provider_locked: false, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 8d1b8a5013..636a7391c3 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -15,7 +15,9 @@ mod runtime_metadata; use presets::{preset_catalog_entry, PRESET_HARNESSES}; pub(crate) use presets::{preset_harness_definitions, preset_harness_ids}; -pub(crate) use runtime_metadata::KnownAcpRuntime; +pub(crate) use runtime_metadata::{ + known_acp_runtime_exact, known_skill_dirs, KnownAcpRuntime, OPENCODE_RUNTIME, +}; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; @@ -93,6 +95,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ adapter_install_hint: "", skill_dir: Some(".goose/skills"), supports_acp_model_switching: false, + supports_account_connection: false, model_env_var: Some("GOOSE_MODEL"), provider_env_var: Some("GOOSE_PROVIDER"), provider_locked: false, @@ -125,6 +128,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", skill_dir: Some(".claude/skills"), supports_acp_model_switching: false, + supports_account_connection: false, model_env_var: None, provider_env_var: None, provider_locked: true, @@ -157,6 +161,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", skill_dir: Some(".codex/skills"), supports_acp_model_switching: false, + supports_account_connection: false, model_env_var: None, provider_env_var: None, provider_locked: false, @@ -172,6 +177,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, + OPENCODE_RUNTIME, KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -190,6 +196,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ adapter_install_hint: "", skill_dir: None, supports_acp_model_switching: true, + supports_account_connection: false, model_env_var: Some("BUZZ_AGENT_MODEL"), provider_env_var: Some("BUZZ_AGENT_PROVIDER"), provider_locked: false, @@ -206,11 +213,6 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ }, ]; -/// Skill discovery directories declared by known runtimes. -pub(crate) fn known_skill_dirs() -> impl Iterator { - KNOWN_ACP_RUNTIMES.iter().filter_map(|p| p.skill_dir) -} - fn workspace_root_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") } @@ -271,10 +273,6 @@ pub(crate) fn known_acp_runtime(command: &str) -> Option<&'static KnownAcpRuntim }) } -pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRuntime> { - KNOWN_ACP_RUNTIMES.iter().find(|p| p.id == id) -} - /// The agent command a freshly-created agent defaults to when the create /// request supplies none. Resolves the bundled `buzz-agent` from the catalog so /// the default cannot drift from the provider definition. Falls back to the id @@ -465,7 +463,7 @@ pub fn try_record_agent_command( fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), + "goose" | "opencode" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, @@ -1421,6 +1419,7 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr node_required, // Filled in by the auth-probe phase in full catalog discovery. auth_status: AuthStatus::Unknown, + supports_account_connection: runtime.supports_account_connection, login_hint: None, source: HarnessSource::Builtin, // Builtin entries have no user-editable env; definition_env is empty. @@ -1580,6 +1579,7 @@ pub fn discover_acp_runtimes_from( node_required: false, // No auth probe for custom harnesses. auth_status: AuthStatus::NotApplicable, + supports_account_connection: false, login_hint: None, source: HarnessSource::Custom, // Carry definition env into the catalog so the edit form can diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index 72c4657dc7..d6e8578b0e 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -76,6 +76,7 @@ pub(super) fn preset_catalog_entry( underlying_cli_path, node_required: false, auth_status: AuthStatus::NotApplicable, + supports_account_connection: false, login_hint: None, source: HarnessSource::Preset, definition_env: Default::default(), @@ -119,15 +120,6 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[ install_hint: "Buzz talks to Grok Build through its CLI's agent stdio mode.", underlying_cli: None, }, - PresetHarness { - id: "opencode", - label: "OpenCode", - command: "opencode", - args: &["acp"], - install_instructions_url: "https://opencode.ai/docs", - install_hint: "Buzz talks to OpenCode through its CLI's ACP mode (opencode acp).", - underlying_cli: None, - }, PresetHarness { id: "kimi", label: "Kimi Code", diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index fdfe9b8be7..b5d1d79605 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -38,6 +38,9 @@ pub(crate) struct KnownAcpRuntime { /// this value. Retained as scaffolding for when ACP model switching matures. #[allow(dead_code)] pub supports_acp_model_switching: bool, + /// Whether the settings UI should offer an account connection even when + /// authentication is optional and therefore has no logged-out probe state. + pub supports_account_connection: bool, pub model_env_var: Option<&'static str>, pub provider_env_var: Option<&'static str>, pub provider_locked: bool, @@ -81,9 +84,56 @@ impl KnownAcpRuntime { } } +pub(crate) const OPENCODE_RUNTIME: KnownAcpRuntime = KnownAcpRuntime { + id: "opencode", + label: "OpenCode", + commands: &["opencode"], + aliases: &[], + avatar_url: "", + mcp_command: None, + mcp_hooks: false, + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "https://opencode.ai/docs/", + adapter_install_instructions_url: "", + cli_install_hint: "Buzz talks to OpenCode through its CLI's ACP mode (opencode acp).", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: true, + supports_account_connection: true, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + config_file_path: Some("~/.config/opencode/opencode.json"), + config_file_format: Some("json"), + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: None, + auth_probe_args: None, +}; + +pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRuntime> { + super::KNOWN_ACP_RUNTIMES + .iter() + .find(|runtime| runtime.id == id) +} + +pub(crate) fn known_skill_dirs() -> impl Iterator { + super::KNOWN_ACP_RUNTIMES + .iter() + .filter_map(|runtime| runtime.skill_dir) +} + #[cfg(test)] mod tests { - use super::super::known_acp_runtime_exact; + use super::super::{discover_acp_runtime_phase1, normalize_agent_args}; + use super::known_acp_runtime_exact; #[test] fn vendor_metadata_distinguishes_cli_and_adapter_guidance() { @@ -121,4 +171,37 @@ mod tests { assert!(codex.adapter_install_instructions_url.contains("codex-acp")); assert!(codex.cli_install_hint.contains("Codex CLI")); } + + #[test] + fn opencode_is_a_native_acp_runtime() { + let opencode = known_acp_runtime_exact("opencode").unwrap(); + + assert_eq!(opencode.commands, &["opencode"]); + assert_eq!( + opencode.config_file_path, + Some("~/.config/opencode/opencode.json") + ); + assert!(opencode.supports_acp_model_switching); + assert!(opencode.supports_account_connection); + assert!(!opencode.supports_acp_native_config); + assert!(opencode.adapter_install_commands.is_empty()); + assert!(opencode.auth_probe_args.is_none()); + } + + #[test] + fn opencode_defaults_to_acp_mode() { + assert_eq!( + normalize_agent_args("opencode", Vec::new()), + vec!["acp".to_string()] + ); + } + + #[test] + fn opencode_catalog_entry_offers_optional_account_connection() { + let runtime = known_acp_runtime_exact("opencode").unwrap(); + let entry = discover_acp_runtime_phase1(runtime).entry; + + assert_eq!(entry.source, crate::managed_agents::HarnessSource::Builtin); + assert!(entry.supports_account_connection); + } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index fa8eb36fa1..53720979fe 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1017,8 +1017,7 @@ mod tests { // ── cli_login_requirements: resolve_command integration ───────────── - /// Construct a minimal `KnownAcpRuntime` stub for testing cli_login_requirements. - /// `commands` are the adapter binaries; `underlying_cli` is the CLI name. + /// Minimal runtime stub: `commands` are adapters; `underlying_cli` is the CLI. fn make_cli_runtime( commands: &'static [&'static str], underlying_cli: Option<&'static str>, @@ -1041,6 +1040,7 @@ mod tests { adapter_install_hint: "", skill_dir: None, supports_acp_model_switching: false, + supports_account_connection: false, config_file_path: None, config_file_format: None, model_env_var: None, @@ -1209,11 +1209,8 @@ mod tests { // ── codex readiness version gate ─────────────────────────────────────── - /// Build a minimal `KnownAcpRuntime` for testing the codex version gate. - /// `adapter_commands` are the exact strings passed to `find_command` — use - /// `&["codex-acp"]` when the binary is on PATH, or `&[]` - /// when resolving via absolute path. `underlying_cli` is a portable - /// stand-in so the adapter is not misclassified as `CliMissing`. + /// Minimal runtime for the codex gate. `adapter_commands` go to `find_command`; + /// `underlying_cli` prevents an adapter from being classified `CliMissing`. fn make_codex_runtime( adapter_commands: &'static [&'static str], underlying_cli: Option<&'static str>, @@ -1236,6 +1233,7 @@ mod tests { adapter_install_hint: "", skill_dir: None, supports_acp_model_switching: false, + supports_account_connection: false, config_file_path: None, config_file_format: None, model_env_var: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index fcd8b13fc9..2a5ce48bd9 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -657,6 +657,7 @@ pub struct AcpRuntimeCatalogEntry { pub node_required: bool, /// Login/authentication status for CLI-based runtimes. pub auth_status: AuthStatus, + pub supports_account_connection: bool, /// Hint for completing authentication, shown when `auth_status` is not `logged_in`. #[serde(skip_serializing_if = "Option::is_none")] pub login_hint: Option, @@ -664,7 +665,6 @@ pub struct AcpRuntimeCatalogEntry { /// JSON file in `custom_harnesses/`. The UI uses this to decide editability. pub source: HarnessSource, /// Definition-level environment variables for `source: custom` entries. - /// /// Populated from `HarnessDefinition.env` so the edit form can read them /// back and the user doesn't silently lose env vars when saving. Always /// empty for `builtin` and `preset` entries (those env values come from the diff --git a/desktop/src/features/onboarding/ui/RuntimeIcon.tsx b/desktop/src/features/onboarding/ui/RuntimeIcon.tsx index 5b247c31f7..d8408b5766 100644 --- a/desktop/src/features/onboarding/ui/RuntimeIcon.tsx +++ b/desktop/src/features/onboarding/ui/RuntimeIcon.tsx @@ -12,6 +12,7 @@ import { RUNTIME_MARKS } from "./HarnessMarks"; // `currentColor`, so they adapt to dark/light without bitmap filters. const RUNTIME_LOGOS: Record = { claude: claudeLogoUrl, + opencode: "/harness-logos/opencode.svg", }; // Public-path logos for bundled presets. Served from /harness-logos/ at runtime. @@ -20,7 +21,6 @@ export const PRESET_LOGOS: Record = { devin: "/harness-logos/devin.svg", omp: "/harness-logos/omp.svg", grok: "/harness-logos/grok.svg", - opencode: "/harness-logos/opencode.svg", kimi: "/harness-logos/kimi.png", amp: "/harness-logos/amp.png", hermes: "/harness-logos/hermes.png", diff --git a/desktop/src/features/settings/ui/HarnessRow.tsx b/desktop/src/features/settings/ui/HarnessRow.tsx index c0feef13cc..07c70ad1fd 100644 --- a/desktop/src/features/settings/ui/HarnessRow.tsx +++ b/desktop/src/features/settings/ui/HarnessRow.tsx @@ -37,6 +37,7 @@ import { Spinner } from "@/shared/ui/spinner"; import { CustomHarnessForm } from "./CustomHarnessForm"; import { adapterUpdateWarning, + canConnectRuntimeAccount, entryStatusLabel, isDownloadPageUrl, } from "./harnessCatalogLogic"; @@ -363,9 +364,7 @@ export function HarnessRow({ }); } - const canConnectAccount = - runtime.availability === "available" && - runtime.authStatus.status === "logged_out"; + const canConnectAccount = canConnectRuntimeAccount(runtime); const authMethodsQuery = useAcpAuthMethodsQuery(runtime.id, { enabled: canConnectAccount, }); diff --git a/desktop/src/features/settings/ui/harnessCatalogLogic.test.mjs b/desktop/src/features/settings/ui/harnessCatalogLogic.test.mjs index 28843bdfd2..be05a56dfb 100644 --- a/desktop/src/features/settings/ui/harnessCatalogLogic.test.mjs +++ b/desktop/src/features/settings/ui/harnessCatalogLogic.test.mjs @@ -3,6 +3,7 @@ import { describe, it } from "node:test"; import { adapterUpdateWarning, + canConnectRuntimeAccount, catalogDialogEntries, catalogPrimaryAction, entryStatusLabel, @@ -36,11 +37,51 @@ function entry(overrides = {}) { underlyingCliPath: null, nodeRequired: false, authStatus: { status: "not_applicable" }, + supportsAccountConnection: false, loginHint: null, ...overrides, }; } +describe("canConnectRuntimeAccount", () => { + it("offers optional account connection for an available runtime", () => { + assert.equal( + canConnectRuntimeAccount( + entry({ + availability: "available", + authStatus: { status: "not_applicable" }, + supportsAccountConnection: true, + }), + ), + true, + ); + }); + + it("keeps probe-driven logged-out runtimes connectable", () => { + assert.equal( + canConnectRuntimeAccount( + entry({ + availability: "available", + authStatus: { status: "logged_out" }, + }), + ), + true, + ); + }); + + it("does not offer connection before the runtime is installed", () => { + assert.equal( + canConnectRuntimeAccount( + entry({ + availability: "not_installed", + supportsAccountConnection: true, + }), + ), + false, + ); + }); +}); + // ── isYourHarnessEntry / yourHarnessEntries ────────────────────────────────── describe("isYourHarnessEntry", () => { diff --git a/desktop/src/features/settings/ui/harnessCatalogLogic.ts b/desktop/src/features/settings/ui/harnessCatalogLogic.ts index 2627135a77..2c24cc4bf7 100644 --- a/desktop/src/features/settings/ui/harnessCatalogLogic.ts +++ b/desktop/src/features/settings/ui/harnessCatalogLogic.ts @@ -15,6 +15,17 @@ const ROW_SORT_PRIORITY: Record = { goose: 1, }; +/** Whether settings should load and offer this runtime's ACP auth methods. */ +export function canConnectRuntimeAccount( + entry: AcpRuntimeCatalogEntry, +): boolean { + return ( + entry.availability === "available" && + (entry.authStatus.status === "logged_out" || + entry.supportsAccountConnection) + ); +} + /** * True when the entry earns a row in "Your runtimes": * diff --git a/desktop/src/shared/api/tauri.test.mjs b/desktop/src/shared/api/tauri.test.mjs index f4692a7d2a..ca7fc330b2 100644 --- a/desktop/src/shared/api/tauri.test.mjs +++ b/desktop/src/shared/api/tauri.test.mjs @@ -184,6 +184,41 @@ test("fromRawAcpRuntimeCatalogEntry preserves source preset", () => { assert.deepStrictEqual(entry.definitionEnv, {}); }); +test("fromRawAcpRuntimeCatalogEntry maps optional account capability", () => { + const entry = fromRawAcpRuntimeCatalogEntry({ + id: "opencode", + label: "OpenCode", + availability: "available", + command: "opencode", + source: "builtin", + default_args: ["acp"], + can_auto_install: false, + requires_external_cli: false, + install_hint: "", + install_instructions_url: "https://opencode.ai/docs/", + supports_account_connection: true, + }); + + assert.equal(entry.supportsAccountConnection, true); +}); + +test("fromRawAcpRuntimeCatalogEntry defaults optional account capability to false", () => { + const entry = fromRawAcpRuntimeCatalogEntry({ + id: "legacy-runtime", + label: "Legacy Runtime", + availability: "available", + command: "legacy-runtime", + source: "preset", + default_args: [], + can_auto_install: false, + requires_external_cli: false, + install_hint: "", + install_instructions_url: "", + }); + + assert.equal(entry.supportsAccountConnection, false); +}); + test("fromRawAcpRuntimeCatalogEntry env round-trips through edit payload shape", () => { // Simulate the full save → re-open cycle: raw entry comes back from Rust // with definition_env populated; the edit form reads entry.definitionEnv. diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 69e2e455ec..dda684fd2b 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -197,12 +197,10 @@ export type RawAcpRuntimeCatalogEntry = { node_required: boolean; /** Tagged union with snake_case status values — same shape as `AuthStatus`. */ auth_status: AuthStatus; + supports_account_connection?: boolean; login_hint?: string; source: "builtin" | "preset" | "custom"; - /** - * Definition-level env vars for `source: custom` entries. - * Omitted/absent for builtin and preset — skipped in Rust serialization when empty. - */ + /** Custom definition env; absent for builtin/preset and empty custom maps. */ definition_env?: Record; }; @@ -756,6 +754,7 @@ export function fromRawAcpRuntimeCatalogEntry( underlyingCliPath: entry.underlying_cli_path, nodeRequired: entry.node_required, authStatus: entry.auth_status, + supportsAccountConnection: entry.supports_account_connection ?? false, loginHint: entry.login_hint ?? null, source: entry.source, // Map definition_env (snake_case from Rust) to definitionEnv (camelCase). diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 3f07e9ad9a..ad0efd7f62 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -527,6 +527,7 @@ export type AcpRuntimeCatalogEntry = { nodeRequired: boolean; /** Login/auth status for CLI-based runtimes. */ authStatus: AuthStatus; + supportsAccountConnection: boolean; /** Hint for completing authentication; null when not applicable or already logged in. */ loginHint: string | null; /** @@ -538,7 +539,6 @@ export type AcpRuntimeCatalogEntry = { source: "builtin" | "preset" | "custom"; /** * Definition-level environment variables for `source: custom` entries. - * * Populated by the backend from `HarnessDefinition.env` so the edit form can * read them back without losing existing env vars on save. Always absent/empty * for `builtin` and `preset` entries. diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7566c82370..a72b3977dc 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -7019,6 +7019,7 @@ function withMockRuntimeConfigMetadata( ...runtime, node_required: runtime.node_required ?? false, auth_status: runtime.auth_status ?? { status: "unknown" }, + supports_account_connection: runtime.supports_account_connection ?? false, model_env_var: "model_env_var" in runtime ? runtime.model_env_var diff --git a/desktop/src/testing/e2eBridgeCustomHarnesses.ts b/desktop/src/testing/e2eBridgeCustomHarnesses.ts index b72ecadd34..a963d3d42c 100644 --- a/desktop/src/testing/e2eBridgeCustomHarnesses.ts +++ b/desktop/src/testing/e2eBridgeCustomHarnesses.ts @@ -96,6 +96,7 @@ export function handleSaveCustomHarness(args: { underlying_cli_path: null, node_required: false, auth_status: { status: "not_applicable" }, + supports_account_connection: false, source: "custom", // Omit definition_env when the env map is empty — mirrors Rust's BTreeMap // serialization which skips empty maps so the field is absent on the wire. diff --git a/docs/superpowers/plans/2026-07-31-opencode-go-runtime.md b/docs/superpowers/plans/2026-07-31-opencode-go-runtime.md new file mode 100644 index 0000000000..5224687049 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-opencode-go-runtime.md @@ -0,0 +1,500 @@ +# OpenCode Go Runtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Promote OpenCode to a first-class Buzz ACP runtime that launches with `opencode acp`, exposes OpenCode's model catalog, and offers the ACP-advertised account connection without making authentication a readiness requirement. + +**Architecture:** `KnownAcpRuntime` remains the single source of runtime capability facts. A new `supports_account_connection` capability is serialized through `AcpRuntimeCatalogEntry`, mapped into the frontend, and consumed by a pure settings helper so the React surface never checks the `opencode` runtime ID. Existing ACP auth and model-discovery bridges remain unchanged and receive `opencode acp`. + +**Tech Stack:** Rust 2021, Tauri 2, React 19, TypeScript, Node test runner, Cargo tests, Biome. + +## Global Constraints + +- Do not add dependencies. +- Do not store, parse, copy, or display OpenCode credentials. +- Do not add OpenCode Go as a direct bundled Buzz Agent provider. +- Do not hard-code OpenCode model IDs; preserve provider-qualified IDs returned by ACP. +- Runtime capability facts originate in `KnownAcpRuntime`, not React components. +- Keep OpenCode authentication optional for readiness because unauthenticated free models remain usable. +- Keep changes minimal; do not refactor unrelated harness catalog code. +- Every commit includes `Co-authored-by: Ralf ` before `Signed-off-by: Ralf `. + +--- + +### Task 1: Promote OpenCode into the authoritative runtime catalog + +**Files:** + +- Modify: `desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs` +- Modify: `desktop/src-tauri/src/managed_agents/discovery.rs` +- Modify: `desktop/src-tauri/src/managed_agents/discovery/presets.rs` +- Modify: `desktop/src-tauri/src/managed_agents/types.rs` +- Modify: `desktop/src-tauri/src/commands/agent_discovery.rs` +- Modify: every test-only `KnownAcpRuntime` literal reported by `rg -n 'KnownAcpRuntime \\{' desktop/src-tauri/src` +- Test: `desktop/src-tauri/src/managed_agents/discovery/tests.rs` +- Test: `desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs` + +**Interfaces:** + +- Consumes: existing `KnownAcpRuntime`, `normalize_agent_args`, `discover_acp_runtimes_from`, and `AcpRuntimeCatalogEntry`. +- Produces: `KnownAcpRuntime::supports_account_connection: bool` and `AcpRuntimeCatalogEntry::supports_account_connection: bool`; one built-in runtime with ID `opencode`, command `opencode`, and default args `["acp"]`. + +- [ ] **Step 1: Add failing runtime and catalog tests** + +Add assertions equivalent to: + +```rust +#[test] +fn opencode_uses_native_acp_command_and_account_connection() { + assert_eq!( + normalize_agent_args("opencode", Vec::new()), + vec!["acp".to_string()] + ); + + let runtime = known_acp_runtime_exact("opencode").expect("OpenCode runtime"); + assert_eq!(runtime.commands, &["opencode"]); + assert!(runtime.supports_acp_model_switching); + assert!(runtime.supports_account_connection); + assert!(runtime.model_env_var.is_none()); + assert!(runtime.provider_env_var.is_none()); +} +``` + +Extend the catalog test that uses an injected executable resolver so it asserts +that exactly one entry has ID `opencode`, its source is `HarnessSource::Builtin`, +its command is `opencode`, its default args are `["acp"]`, and +`supports_account_connection` is true. + +- [ ] **Step 2: Run the focused Rust tests and confirm the red state** + +Run: + +```bash +. ./bin/activate-hermit +cargo test -p buzz-desktop managed_agents::discovery +``` + +Expected: compilation or assertions fail because OpenCode is still a preset and +the capability fields do not exist. + +- [ ] **Step 3: Add the runtime capability fields** + +Add to `KnownAcpRuntime`: + +```rust +/// Whether settings may offer the runtime's ACP-advertised account connection +/// even when authentication is not a readiness requirement. +pub supports_account_connection: bool, +``` + +Add to `AcpRuntimeCatalogEntry`: + +```rust +/// Whether the runtime exposes an optional ACP account-connection flow. +pub supports_account_connection: bool, +``` + +Set the field from `runtime.supports_account_connection` in the built-in +catalog projection. Set it to `false` in preset and custom constructors and in +test fixtures unless the fixture specifically represents OpenCode. + +- [ ] **Step 4: Move OpenCode from preset to built-in metadata** + +Remove the `PresetHarness { id: "opencode", ... }` entry. Add a +`KnownAcpRuntime` entry with these exact behavioral values: + +```rust +KnownAcpRuntime { + id: "opencode", + label: "OpenCode", + commands: &["opencode"], + aliases: &[], + mcp_command: None, + mcp_hooks: false, + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "https://opencode.ai/docs/", + adapter_install_instructions_url: "", + cli_install_hint: "Buzz talks to OpenCode through the OpenCode CLI's ACP mode.", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: true, + supports_account_connection: true, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + config_file_path: Some("~/.config/opencode/opencode.json"), + config_file_format: Some("json"), + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: None, + auth_probe_args: None, + // Existing avatar_url field uses the bundled-ID icon path in the frontend; + // keep the backend URL empty instead of introducing a remote asset. + avatar_url: "", +} +``` + +Extend `default_agent_args` so `"opencode"` returns +`Some(vec!["acp".to_string()])`. + +- [ ] **Step 5: Run the focused Rust tests and confirm the green state** + +Run: + +```bash +. ./bin/activate-hermit +cargo test -p buzz-desktop managed_agents::discovery +``` + +Expected: all discovery tests pass. + +- [ ] **Step 6: Commit the backend catalog change** + +```bash +git add desktop/src-tauri/src/managed_agents/discovery.rs \ + desktop/src-tauri/src/managed_agents/discovery \ + desktop/src-tauri/src/managed_agents/types.rs \ + desktop/src-tauri/src/commands/agent_discovery.rs +git commit -m "feat(desktop): promote OpenCode runtime" \ + -m "Co-authored-by: Ralf " -s +``` + +--- + +### Task 2: Expose optional account connection in the settings UI + +**Files:** + +- Modify: `desktop/src/shared/api/tauri.ts` +- Modify: `desktop/src/shared/api/types.ts` +- Test: `desktop/src/shared/api/tauri.test.mjs` +- Modify: `desktop/src/features/settings/ui/harnessCatalogLogic.ts` +- Modify: `desktop/src/features/settings/ui/harnessCatalogLogic.test.mjs` +- Modify: `desktop/src/features/settings/ui/HarnessRow.tsx` +- Modify: `desktop/src/testing/e2eBridge.ts` + +**Interfaces:** + +- Consumes: backend JSON field `supports_account_connection`. +- Produces: frontend property `supportsAccountConnection: boolean` and pure function `canConnectRuntimeAccount(entry: AcpRuntimeCatalogEntry): boolean`. + +- [ ] **Step 1: Add failing frontend mapping and behavior tests** + +Extend the catalog-entry factory with: + +```js +supportsAccountConnection: false, +``` + +Import `canConnectRuntimeAccount` and add: + +```js +describe("canConnectRuntimeAccount", () => { + it("connects a mandatory logged-out runtime", () => { + assert.equal( + canConnectRuntimeAccount( + entry({ + availability: "available", + authStatus: { status: "logged_out" }, + }), + ), + true, + ); + }); + + it("connects an optional account-capable runtime without blocking readiness", () => { + assert.equal( + canConnectRuntimeAccount( + entry({ + availability: "available", + authStatus: { status: "not_applicable" }, + supportsAccountConnection: true, + }), + ), + true, + ); + }); + + it("does not connect unavailable or non-capable runtimes", () => { + assert.equal( + canConnectRuntimeAccount( + entry({ + availability: "not_installed", + supportsAccountConnection: true, + }), + ), + false, + ); + assert.equal( + canConnectRuntimeAccount(entry({ availability: "available" })), + false, + ); + }); +}); +``` + +Add a raw-mapping assertion that +`supports_account_connection: true` becomes +`supportsAccountConnection: true`. + +- [ ] **Step 2: Run the focused frontend tests and confirm the red state** + +Run: + +```bash +# From desktop/ after activating ../bin/activate-hermit: +node --import ./test-loader.mjs --experimental-strip-types --test \ + src/features/settings/ui/harnessCatalogLogic.test.mjs \ + src/shared/api/tauri.test.mjs +``` + +Expected: the import or assertions fail because the helper and mapped property +do not exist. + +- [ ] **Step 3: Map the backend capability into TypeScript** + +Add the optional raw field: + +```ts +supports_account_connection?: boolean; +``` + +Add the required frontend field: + +```ts +/** True when settings may offer an ACP account-connection action. */ +supportsAccountConnection: boolean; +``` + +Map it with a backward-compatible default: + +```ts +supportsAccountConnection: entry.supports_account_connection ?? false, +``` + +Add `supports_account_connection: false` to the E2E bridge's default raw runtime +objects or normalize it in `withMockRuntimeConfigMetadata`. OpenCode-specific +test fixtures set it to `true`. + +- [ ] **Step 4: Implement and consume the pure connection predicate** + +Add: + +```ts +export function canConnectRuntimeAccount( + entry: AcpRuntimeCatalogEntry, +): boolean { + return ( + entry.availability === "available" && + (entry.authStatus.status === "logged_out" || + entry.supportsAccountConnection) + ); +} +``` + +Import the helper in `HarnessRow.tsx` and replace the inline +`availability === "available" && authStatus === "logged_out"` expression with: + +```ts +const canConnectAccount = canConnectRuntimeAccount(runtime); +``` + +Keep onboarding readiness unchanged: optional OpenCode authentication must not +turn a usable runtime into a blocked one. + +- [ ] **Step 5: Run frontend unit tests and typecheck** + +Run: + +```bash +. ./bin/activate-hermit +pnpm --dir desktop test +pnpm --dir desktop typecheck +``` + +Expected: all frontend unit tests and TypeScript checks pass. + +- [ ] **Step 6: Commit the frontend capability change** + +```bash +git add desktop/src/shared/api/tauri.ts \ + desktop/src/shared/api/tauri.test.mjs \ + desktop/src/shared/api/types.ts \ + desktop/src/features/settings/ui/HarnessRow.tsx \ + desktop/src/features/settings/ui/harnessCatalogLogic.ts \ + desktop/src/features/settings/ui/harnessCatalogLogic.test.mjs \ + desktop/src/testing/e2eBridge.ts +git commit -m "feat(desktop): connect optional OpenCode account" \ + -m "Co-authored-by: Ralf " -s +``` + +--- + +### Task 3: Verify the ACP-advertised OpenCode login command + +**Files:** + +- Test: `desktop/src-tauri/src/commands/agent_auth.rs` + +**Interfaces:** + +- Consumes: existing `adapter_terminal_argv(runtime_label, method, fallback_command)`. +- Produces: regression coverage proving Buzz executes OpenCode's advertised terminal-auth command without constructing or storing credentials. + +- [ ] **Step 1: Add the OpenCode terminal-auth regression test** + +Inside the existing `agent_auth.rs` test module, add: + +```rust +#[test] +fn opencode_terminal_auth_uses_advertised_command() { + let method = AcpAuthMethod { + id: "opencode-login".to_string(), + name: "OpenCode login".to_string(), + description: None, + method_type: Some("terminal".to_string()), + args: Vec::new(), + command: Vec::new(), + meta: Some(serde_json::json!({ + "terminal-auth": { + "command": "opencode", + "args": ["auth", "login"] + } + })), + }; + + assert_eq!( + adapter_terminal_argv("OpenCode", &method, "fallback").expect("argv"), + vec!["opencode", "auth", "login"] + ); +} +``` + +If command resolution returns an absolute path in the test environment, compare +`Path::new(&argv[0]).file_name()` with `Some(OsStr::new("opencode"))` and compare +`&argv[1..]` with `["auth", "login"]`. + +- [ ] **Step 2: Run the focused auth test** + +Run: + +```bash +. ./bin/activate-hermit +cargo test -p buzz-desktop opencode_terminal_auth_uses_advertised_command +``` + +Expected: PASS using the existing generic terminal-auth implementation; no +production auth code is added. + +- [ ] **Step 3: Commit the auth regression coverage** + +```bash +git add desktop/src-tauri/src/commands/agent_auth.rs +git commit -m "test(desktop): cover OpenCode ACP login command" \ + -m "Co-authored-by: Ralf " -s +``` + +--- + +### Task 4: Run complete verification and prepare review + +**Files:** + +- Review: all files changed since `main` +- Update only if behavior rules changed: `desktop/src/features/agents/AGENTS.md` + +**Interfaces:** + +- Consumes: Tasks 1-3. +- Produces: a clean, fully tested feature branch ready for pull request review. + +- [ ] **Step 1: Run formatters on touched code** + +Run: + +```bash +. ./bin/activate-hermit +cargo fmt --all +pnpm --dir desktop format +``` + +Inspect `git status` immediately and revert no unrelated formatter changes; +retain only files in this plan. + +- [ ] **Step 2: Run the complete affected-package suites** + +Run: + +```bash +. ./bin/activate-hermit +cargo test -p buzz-desktop +pnpm --dir desktop test +pnpm --dir desktop typecheck +pnpm --dir desktop check +``` + +Expected: every command exits 0. + +- [ ] **Step 3: Run the repository CI gate** + +Run: + +```bash +. ./bin/activate-hermit +just ci +``` + +Expected: format, Clippy, desktop lint, unit tests, and builds all pass. If the +environment lacks an existing tool or service, report the exact command and +error; do not claim the gate passed. + +- [ ] **Step 4: Self-review the exact branch state** + +Run: + +```bash +git diff main...HEAD --check +git diff main...HEAD +git status --short +git rev-parse HEAD +``` + +Check for duplicate OpenCode catalog rows, debug output, secret handling, +hard-coded model IDs, hard-coded runtime-ID checks in React, and missing +constructor fields. Confirm `desktop/src/features/agents/AGENTS.md` remains +accurate; if no rule changed, record “no agent-config rules changed” in the PR. + +- [ ] **Step 5: Verify commit trailers** + +Run: + +```bash +git log --format=full main..HEAD +``` + +Expected: every commit contains both: + +```text +Co-authored-by: Ralf +Signed-off-by: Ralf +``` + +- [ ] **Step 6: Push and open the channel-linked pull request** + +Push the feature branch, then run: + +```bash +buzz pr open --help +buzz pr open --channel c0617041-4eca-5168-8e65-a8ca096b4e0e +``` + +Use the discovered CLI flags for repository, base `main`, head +`codex/opencode-go-runtime`, title, and a body containing the implementation +summary, exact checks, known limitations, and “no agent-config rules changed.” diff --git a/docs/superpowers/specs/2026-07-31-opencode-go-runtime-design.md b/docs/superpowers/specs/2026-07-31-opencode-go-runtime-design.md new file mode 100644 index 0000000000..49ca3c53f4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-opencode-go-runtime-design.md @@ -0,0 +1,152 @@ +# OpenCode Go Runtime Integration + +## Goal + +Make OpenCode a fully supported Buzz agent runtime so a user with an OpenCode +Go subscription can: + +1. select OpenCode in Buzz; +2. complete OpenCode's official login flow; +3. select a model exposed by the authenticated OpenCode CLI; and +4. run the agent through `opencode acp`. + +Buzz must not implement OpenCode Go's API, billing, credential storage, or model +catalog itself. + +## Current State + +Buzz `main` contains OpenCode as a tier-2 preset. The preset makes the harness +visible, supplies `opencode acp`, and permits ACP model discovery. It has no +entry in the authoritative `KnownAcpRuntime` catalog, however, so it cannot +declare first-class runtime capabilities or use Buzz's ACP account-connection +bridge. + +OpenCode's ACP implementation already advertises: + +- the `opencode-login` authentication method; +- terminal-auth metadata for `opencode auth login`; +- a model configuration option containing provider-qualified model IDs; and +- ACP model switching. + +## Design + +### Runtime Catalog + +Move OpenCode from `PRESET_HARNESSES` into `KNOWN_ACP_RUNTIMES`. + +The built-in runtime entry will define: + +- runtime ID and label: `opencode` / `OpenCode`; +- executable: `opencode`; +- default arguments: `acp`; +- no separate ACP adapter or underlying CLI; +- native ACP model switching; +- no provider, model, or thinking environment variables; +- no required normalized fields; +- optional ACP account connection; +- official OpenCode installation guidance; and +- no mandatory login-status probe. + +`default_agent_args` will recognize `opencode` and return `["acp"]`. This keeps +runtime discovery, model discovery, readiness checks, spawn hashing, and actual +agent launch on the same argument normalization path. + +Removing the preset entry prevents duplicate OpenCode rows in the runtime +catalog. + +### Authentication + +OpenCode supports unauthenticated free models, and its supported CLI versions +do not expose one stable, non-interactive login-status command. Buzz will +therefore treat OpenCode authentication as optional for readiness instead of +guessing status from credential files or command output. + +Add a `supports_account_connection` capability to `KnownAcpRuntime` and project +it through `AcpRuntimeCatalogEntry`. OpenCode sets it to `true`. Settings uses +the capability, rather than a hard-coded runtime ID, to offer account +connection even when authentication is not a readiness requirement. + +The connection action uses Buzz's existing ACP authentication bridge: + +1. `buzz-acp auth-methods --json` starts `opencode acp`; +2. OpenCode advertises `opencode-login` and terminal-auth metadata; +3. Buzz opens the advertised `opencode auth login` command in a visible + terminal; and +4. OpenCode stores and refreshes its own credentials; and +5. Buzz refreshes runtime and model discovery after the terminal flow starts. + +Buzz will not read, copy, persist, or display the OpenCode API key. This also +keeps OpenCode Go and any other OpenCode account plans behind the same official +authentication contract. + +### Model Discovery and Selection + +Before an agent is created, Buzz will run its existing ACP model discovery +against `opencode acp`. OpenCode's stable model config option is normalized into +Buzz model options using the existing provider-qualified IDs. + +The UI continues to show `OpenCode` as the runtime. OpenCode Go is represented +by the models that OpenCode exposes after the user's account is authenticated, +not by a separate hard-coded Buzz provider row. + +When a model is selected, Buzz stores the provider-qualified model ID in its +existing normalized model field. The existing `BUZZ_ACP_MODEL` startup path +applies the selection through ACP. Live model changes continue through the +existing ACP config-option/session-model mechanisms. + +### Errors + +Existing runtime states remain authoritative: + +- missing `opencode`: show the official installation guidance; +- optional account connection: keep the runtime ready while exposing the + connection action in settings; +- authentication required during model discovery or start: show the existing + sign-in guidance without reading OpenCode's credential files; +- successful discovery with no models: retain the existing warning and retry + behavior when the screen is reopened; +- ACP command failure: surface the redacted subprocess error through the + existing discovery and start paths. + +No OpenCode-specific secret parsing or fallback login command will be added. +The terminal command advertised by OpenCode remains the source of truth. + +## Testing + +Add or update tests that verify: + +- `opencode` resolves as a built-in `KnownAcpRuntime`; +- `normalize_agent_args("opencode", [])` returns `["acp"]`; +- the preset catalog no longer contains an OpenCode duplicate; +- the built-in catalog entry exposes native model behavior and appropriate + install/account-connection metadata; +- the settings harness row offers account connection from the catalog + capability without checking the `opencode` runtime ID; +- ACP auth metadata for OpenCode produces the advertised terminal command + without a Buzz-owned credential path; +- model discovery receives `opencode acp` and preserves provider-qualified + model IDs; and +- affected desktop agent-config contract and onboarding acceptance tests still + pass. + +The full test suite for each touched package will run before completion. The +repository-level `just ci` gate will run before opening the pull request when +the local environment supports it. + +## Non-Goals + +- Adding OpenCode Go as a direct provider for the bundled Buzz Agent. +- Reimplementing OpenCode's model API or `/connect` flow. +- Storing OpenCode credentials in Buzz. +- Filtering or hard-coding OpenCode Go models. +- Changing unrelated harness catalog behavior or configuration UI. + +## Success Criteria + +- A user with the OpenCode CLI installed sees one OpenCode runtime in Buzz. +- Buzz can launch the official OpenCode login flow. +- After authentication, Buzz shows the model choices returned by OpenCode. +- A selected model is used when the managed agent starts through + `opencode acp`. +- Users without OpenCode installed or authenticated receive actionable, + non-secret-bearing errors.