diff --git a/README.md b/README.md index 80cdc0e5d..b410771b6 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ parallel = 3 # how many Tickets a Spec run runs at once; default 3 limit = 3 # how many open issues labelled in-progress stop a Pickup run taking another; default 3 [harness] -default = "claude" # the Harness every Run's sessions run on, claude, codex, agy, grok or muse; default claude +default = "claude" # the Harness every Run's sessions run on, claude, codex, agy, grok, muse or opencode; default claude [harness.claude] model = "" # the Model Claude Code's sessions run on; default blank, for Claude Code's own @@ -271,11 +271,15 @@ effort = "" # how hard that Model reasons; default blank, for Grok Build's own [harness.muse] model = "" # the Model Muse Code's sessions run on; default blank, for Muse Code's own effort = "" # how hard that Model reasons; default blank, for Muse Code's own + +[harness.opencode] +model = "" # the Model OpenCode's sessions run on, provider/model; default blank, for OpenCode's own +effort = "" # the Model's variant, passed as #effort; default blank, for OpenCode's own ``` Every key holds its real value, so a Run reading it does exactly what it does with no file. `email.to` has no default, so `setup` suggests one: the public email of your GitHub profile (from `gh api user`), else your global git `user.email`, unless that is a `@users.noreply.github.com` address, which can't receive mail. With neither, `email.to` is the only line written commented out, as above. `setup` never asks `gh` for more scopes, so a private GitHub email is not read, and a Run never looks the suggestion up: `--email` with no address and no `email.to` still stops the Run. From a terminal (stdin and stderr both terminals), `setup` first asks, on stderr: -1. The Harness for every Run's sessions (`harness.default`), listing only those installed on `PATH`. An unavailable Harness or unknown name is refused and asked again. The default is the configured Harness when installed, else the first installed in `claude`, `codex`, `agy`, `grok`, `muse` order. Then the Model (`harness..model`) and the Effort (`harness..effort`) for the Harness chosen only, each defaulting to the User config's, where Enter on none keeps the Harness's own default and `-` clears one. They're checked as a Run checks them (see [Harness, Model and Effort](#harness-model-and-effort)). For Claude, a named Model gets the test call; if Claude refuses it, Setup shows what Claude said and asks the Model and Effort again. For Codex, Setup lists the Models `codex debug models` offers, by slug and display name, then the Efforts the Model chosen supports; each answer is matched regardless of case and written as Codex names it, so `GPT-6.1-Sol` and `Max` are written as `gpt-6.1-sol` and `max`, and a Model or Effort the catalog doesn't have is asked again, with the valid choices. For agy, Setup lists `agy models` and its supported Efforts, checks each answer without a model turn, and writes the canonical names regardless of case. For Grok Build, Setup lists `grok models` and the chosen Model's Efforts from its refreshed cache, and checks each answer against them. Muse proposes `muse-spark-1.3` and checks the answers against its cache, or with a minimal call; a refusal asks for the Model and Effort again. The other Harnesses' sections are left as they are. With no Harness installed here, Setup says so, asks none of this, and leaves the harness settings as they are. +1. The Harness for every Run's sessions (`harness.default`), listing only those installed on `PATH`. An unavailable Harness or unknown name is refused and asked again. The default is the configured Harness when installed, else the first installed in `claude`, `codex`, `agy`, `grok`, `muse`, `opencode` order. Then the Model (`harness..model`) and the Effort (`harness..effort`) for the Harness chosen only, each defaulting to the User config's, where Enter on none keeps the Harness's own default and `-` clears one. They're checked as a Run checks them (see [Harness, Model and Effort](#harness-model-and-effort)). For Claude, a named Model gets the test call; if Claude refuses it, Setup shows what Claude said and asks the Model and Effort again. For Codex, Setup lists the Models `codex debug models` offers, by slug and display name, then the Efforts the Model chosen supports; each answer is matched regardless of case and written as Codex names it, so `GPT-6.1-Sol` and `Max` are written as `gpt-6.1-sol` and `max`, and a Model or Effort the catalog doesn't have is asked again, with the valid choices. For agy, Setup lists `agy models` and its supported Efforts, checks each answer without a model turn, and writes the canonical names regardless of case. For Grok Build, Setup lists `grok models` and the chosen Model's Efforts from its refreshed cache, and checks each answer against them. Muse proposes `muse-spark-1.3` and checks the answers against its cache, or with a minimal call; a refusal asks for the Model and Effort again. OpenCode proposes no Model and checks the Model and Effort with a minimal standalone call, asking again on a refusal. The other Harnesses' sections are left as they are. With no Harness installed here, Setup says so, asks none of this, and leaves the harness settings as they are. 2. Every Run a Merge run? (`merge.always`) 3. With that on, every Run may start a Base fix when the Base branch's CI is red? (`base.fix`). With it off, this is not asked, and `base.fix` is written at its default, `false`. 4. Every Run first fast-forwards your checkout of the Base branch? (`launch.pull`) @@ -306,7 +310,7 @@ A Run reads the file before any work. One that isn't valid TOML, or that has a k ### Harness, Model and Effort -The available Harnesses are Claude Code (`claude`), Codex (`codex`), Antigravity CLI (`agy`), Grok Build (`grok`) and Muse Code (`muse`). +The available Harnesses are Claude Code (`claude`), Codex (`codex`), Antigravity CLI (`agy`), Grok Build (`grok`), Muse Code (`muse`) and OpenCode (`opencode`). Every session of a command, its implement session, each Repair and Resume, a Spec review, an Architecture review, each Ticket's Run and a Base fix, runs on one **Harness**, with one **Model** and one **Effort**. `harness `, `model ` and `effort ` (or `--harness`, `--model` and `--effort`) choose them for one command, before or after the Issue URL, and on `architect` (but not with `--plan-only`) and `pickup` too: @@ -338,6 +342,12 @@ Muse's named Model is checked against its cached catalog under `~/.local/share/m Muse's session id comes from `stream.id`, and a Resume uses `--session-id ` with every flag and environment override again. Progress lines name session starts, tools and errors. After exit, thirdshift reads the last reply and token totals from Muse's own `sessions/////session.jsonl`. Architecture reviews see that last reply alone, rather than Muse's terminal event's joined replies. A missing or unreadable log falls back to the stream text with no usage. `run.terminal.failed` or a non-zero exit fails the Run with Muse's error. An interrupt sends SIGTERM, then SIGKILL after ten seconds if needed. +With `harness opencode`, every session, Resume and preflight check runs `opencode run --standalone --format json --auto`, with the prompt on stdin and `OPENCODE_DISABLE_AUTOUPDATE=1`. Standalone starts a private server in the session's process tree and adds about 2–3 seconds per session; it keeps the work and its commands under thirdshift's interruption handling rather than in the shared service ([ADR-0013](docs/adr/0013-every-harness-runs-unattended-and-fully-trusted.md)). OpenCode reads project `AGENTS.md` and `~/.config/opencode/AGENTS.md`; it never reads `CLAUDE.md` or `~/.claude/CLAUDE.md` itself. When root `CLAUDE.md` exists and `AGENTS.md` does not, thirdshift links `AGENTS.md` to it and excludes that link from git. Factory skills are linked into `.agents/skills/`, and the prompt tells the model to load its skill with the skill tool. A missing successful `skill` load produces a warning without failing or retrying the session. + +OpenCode takes the Model id from your provider config, as `/`, including MiMo-V2.6-Pro when your provider routes it. Model and Effort are passed together as `-m /#`; an explicit Effort replaces a variant already in the Model id, and needs a Model set too. Before any work, thirdshift makes a minimal standalone test call with the same stdin, flags and environment. `provider.no-route` identifies a bad Model or Effort without a model turn; a successful check uses a model call because OpenCode has no free catalog without its service. Setup checks its answers the same way, proposing no default Model. + +The session id comes from `sessionID` in the stream, and a Resume uses `-s ` with every flag and environment override again. OpenCode creates a new session for an unknown id, so thirdshift only resumes an id it read from the stream. Progress comes from `step_start`, `tool_use`, `step_finish` and `error`. After exit, thirdshift reads `opencode session export --standalone `: an exported `failed` outcome fails the Run even with exit 0, the final message is the last assistant message's last text part, and usage and cost are summed over assistant messages to exclude the automatic title call. Input totals include cache reads and writes, output totals include reasoning, and each subtotal is shown. If export fails or is unreadable, the last stream `text` supplies the final message and no usage is shown. A stream error or nonzero exit also fails the Run with OpenCode's error. An interrupt sends SIGTERM through the whole process tree, then SIGKILL after the grace period if needed. + The Command log's opening lines (`sessions run on claude · claude-opus-5-5 · high`), the Activity log's start line, the pull request's body and the Run notification each name the Harness, Model and Effort, `default model` or `default effort` where the Harness's own is used. In the pull request's body, the Spec PR's included, thirdshift writes the line itself once the opening session has ended, such as `Built with claude · claude-opus-5-5 · high`, replacing any it wrote there before. ### Email diff --git a/src/args.rs b/src/args.rs index aa7acf88b..f84d668de 100644 --- a/src/args.rs +++ b/src/args.rs @@ -995,11 +995,11 @@ mod tests { for (args, error) in [ ( vec![URL, "harness"], - "harness must be followed by claude or codex or agy or grok or muse", + "harness must be followed by claude or codex or agy or grok or muse or opencode", ), ( vec![URL, "harness", "gemini"], - "harness must be followed by claude or codex or agy or grok or muse, not gemini", + "harness must be followed by claude or codex or agy or grok or muse or opencode, not gemini", ), ( vec!["--harness", "claude", URL, "harness", "codex"], diff --git a/src/config.rs b/src/config.rs index 24b16d178..c86b6eaaa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -387,7 +387,7 @@ parallel = 3 # how many Tickets a Spec run runs at once; default 3 limit = 3 # how many open issues labelled in-progress stop a Pickup run taking another; default 3 [harness] -default = "claude" # the Harness every Run's sessions run on, claude, codex, agy, grok or muse; default claude +default = "claude" # the Harness every Run's sessions run on, claude, codex, agy, grok, muse or opencode; default claude [harness.claude] model = "" # the Model Claude Code's sessions run on; default blank, for Claude Code's own @@ -408,6 +408,10 @@ effort = "" # how hard that Model reasons; default blank, for Grok Build's own [harness.muse] model = "" # the Model Muse Code's sessions run on; default blank, for Muse Code's own effort = "" # how hard that Model reasons; default blank, for Muse Code's own + +[harness.opencode] +model = "" # the Model OpenCode's sessions run on, provider/model; default blank, for OpenCode's own +effort = "" # the Model's variant, passed as #effort; default blank, for OpenCode's own "#; /// The line `DEFAULTS` holds for `email.to`, which has no default. @@ -545,7 +549,9 @@ mod tests { "harness.grok.model", "harness.grok.effort", "harness.muse.model", - "harness.muse.effort" + "harness.muse.effort", + "harness.opencode.model", + "harness.opencode.effort" ] ); let commented_out: Vec<&str> = DEFAULTS @@ -606,7 +612,7 @@ mod tests { for (text, error) in [ ( "[harness]\ndefault = \"gemini\"\n", - "harness.default must be claude or codex or agy or grok or muse, not \"gemini\"", + "harness.default must be claude or codex or agy or grok or muse or opencode, not \"gemini\"", ), ( "[harness]\ndefault = true\n", diff --git a/src/harness.rs b/src/harness.rs index c70379066..207ae8bb4 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -16,7 +16,9 @@ pub(crate) mod codex; pub(crate) mod grok; mod instructions; pub(crate) mod muse; +pub(crate) mod opencode; mod process_tree; +mod skill_load; pub use adapter::{Adapter, Invocation}; pub use codex::Catalog; @@ -29,6 +31,7 @@ pub enum Harness { Agy, Grok, Muse, + OpenCode, } impl Harness { @@ -104,6 +107,8 @@ pub struct Settings { pub grok: ModelAndEffort, /// `[harness.muse]`. pub muse: ModelAndEffort, + /// `[harness.opencode]`. + pub opencode: ModelAndEffort, } impl Settings { diff --git a/src/harness/adapter.rs b/src/harness/adapter.rs index 18c6e7a81..f962bfcae 100644 --- a/src/harness/adapter.rs +++ b/src/harness/adapter.rs @@ -5,7 +5,7 @@ use std::process::Child; use anyhow::Result; -use super::{Choice, Harness, ModelAndEffort, Settings, agy, claude, codex, grok, muse}; +use super::{Choice, Harness, ModelAndEffort, Settings, agy, claude, codex, grok, muse, opencode}; use crate::progress::Stream; use crate::setup::Outside; @@ -90,10 +90,11 @@ impl SkillLoading { } /// Registration comes from the adapters, including Setup's order. -pub const REGISTERED: [(Harness, &dyn Adapter); 5] = [ +pub const REGISTERED: [(Harness, &dyn Adapter); 6] = [ claude::REGISTRATION, codex::REGISTRATION, agy::REGISTRATION, grok::REGISTRATION, muse::REGISTRATION, + opencode::REGISTRATION, ]; diff --git a/src/harness/muse/stream.rs b/src/harness/muse/stream.rs index e0a77a166..cd2938004 100644 --- a/src/harness/muse/stream.rs +++ b/src/harness/muse/stream.rs @@ -1,5 +1,6 @@ //! Muse's JSONL event envelopes. Usage is read from its session log after exit. +use crate::harness::skill_load::SkillLoad; use crate::progress::{Stream, bash, shorten}; use serde_json::Value; @@ -9,8 +10,7 @@ pub struct MuseProgress { started: bool, final_message: Option, failure: Option, - expected_skill: Option, - skill_loaded: bool, + skill_load: SkillLoad, tasks: Vec<(String, String)>, killed: Vec, } @@ -18,11 +18,7 @@ pub struct MuseProgress { impl MuseProgress { pub fn for_prompt(prompt: &str) -> Self { Self { - expected_skill: prompt - .strip_prefix('/') - .and_then(|rest| rest.split_whitespace().next()) - .filter(|name| name.starts_with("thirdshift-")) - .map(String::from), + skill_load: SkillLoad::for_prompt(prompt), ..Self::default() } } @@ -38,11 +34,8 @@ impl MuseProgress { .and_then(|rest| rest.split_once('"')) .map(|(name, _)| name) .unwrap_or(""); - if facts["outcome"] == "success" - && header.contains("status=\"ok\"") - && self.expected_skill.as_deref() == Some(skill) - { - self.skill_loaded = true; + if facts["outcome"] == "success" && header.contains("status=\"ok\"") { + self.skill_load.observed(skill); } return vec![format!("skill {skill}")]; } @@ -158,15 +151,7 @@ impl Stream for MuseProgress { self.failure.as_deref() } fn warnings(&self) -> Vec { - self.expected_skill - .as_ref() - .filter(|_| !self.skill_loaded) - .map(|skill| { - vec![format!( - "warning: the session never loaded {skill} with its skill tool" - )] - }) - .unwrap_or_default() + self.skill_load.warnings() } } diff --git a/src/harness/opencode.rs b/src/harness/opencode.rs new file mode 100644 index 000000000..568917380 --- /dev/null +++ b/src/harness/opencode.rs @@ -0,0 +1,241 @@ +//! OpenCode: private standalone sessions, stdin prompts and an authoritative export. +mod export; +mod stream; + +use super::adapter::{Adapter, Invocation, SkillLoading}; +use super::{Choice, Harness, ModelAndEffort, Settings, said}; +use crate::progress::Stream; +use crate::setup::{Outside, questions::ask_setting}; +use anyhow::{Context, Result, anyhow, bail}; +use std::io::{Read, Write}; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::{Child, Command, Output, Stdio}; +use std::thread; +use std::time::Duration; + +pub struct OpenCode; +pub const REGISTRATION: (Harness, &dyn Adapter) = (Harness::OpenCode, &OpenCode); + +impl Adapter for OpenCode { + fn name(&self) -> &'static str { + "opencode" + } + fn product_name(&self) -> &'static str { + "OpenCode" + } + fn project_skills(&self) -> &'static str { + ".agents/skills" + } + fn skill_loading(&self) -> SkillLoading { + SkillLoading::Tool + } + fn stop_signals(&self) -> &'static [libc::c_int] { + &[libc::SIGTERM] + } + fn environment(&self) -> &'static [(&'static str, &'static str)] { + &[("OPENCODE_DISABLE_AUTOUPDATE", "1")] + } + fn settings<'a>(&self, settings: &'a Settings) -> &'a ModelAndEffort { + &settings.opencode + } + fn settings_mut<'a>(&self, settings: &'a mut Settings) -> &'a mut ModelAndEffort { + &mut settings.opencode + } + fn session(&self, choice: &Choice, resume: Option<&str>, prompt: &str) -> Invocation { + let mut args = ["run", "--standalone", "--format", "json", "--auto"] + .map(String::from) + .to_vec(); + if let Some(model) = &choice.model { + let model = if let Some(effort) = &choice.effort { + format!("{}#{effort}", model.split('#').next().unwrap_or(model)) + } else { + model.clone() + }; + args.extend(["-m".into(), model]); + } + if let Some(id) = resume { + args.extend(["-s".into(), id.into()]); + } + Invocation { + args, + stdin: Some(self.skill_loading().prompt(prompt)), + } + } + fn check(&self, choice: &mut Choice) -> Result<()> { + check_model_and_effort(&ModelAndEffort { + model: choice.model.clone(), + effort: choice.effort.clone(), + }) + } + fn ask_settings( + &self, + outside: &mut dyn Outside, + current: &ModelAndEffort, + ) -> Result> { + loop { + let chosen = ModelAndEffort { + model: ask_setting( + outside, + "Model", + Harness::OpenCode, + current.model.as_deref(), + )?, + effort: ask_setting( + outside, + "Effort", + Harness::OpenCode, + current.effort.as_deref(), + )?, + }; + match outside.opencode_check(&chosen) { + Ok(()) => return Ok(Some(chosen)), + Err(error) => outside.say(format!("{error:#}")), + } + } + } + fn stream(&self, _worktree: &Path, prompt: &str) -> Box { + Box::new(stream::OpenCodeProgress::for_prompt(prompt)) + } + fn link_instruction_fallback(&self, worktree: &Path) -> Result<()> { + super::instructions::link_fallback(worktree, "AGENTS.md", &[]) + } + fn read_after_exit(&self, worktree: &Path, stream: Box) -> Box { + export::read_after_exit(worktree, stream) + } +} + +/// Setup and Commands make the same minimal call: there is no free catalog +/// without the shared service, and an invalid route fails before a model turn. +pub fn check_model_and_effort(chosen: &ModelAndEffort) -> Result<()> { + if chosen.effort.is_some() && chosen.model.is_none() { + bail!("the OpenCode Effort needs a Model: set model / too"); + } + let choice = Choice { + harness: Harness::OpenCode, + model: chosen.model.clone(), + effort: chosen.effort.clone(), + chosen_by: super::ChosenBy::UserConfig, + }; + crate::progress::step("checking OpenCode's Model and Effort with a standalone test call"); + let invocation = OpenCode.session(&choice, None, "Reply with OK."); + let mut child = Command::new(OpenCode.name()) + .args(invocation.args) + .envs(OpenCode.environment().iter().copied()) + .process_group(0) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("could not run opencode to check the Model and Effort")?; + let written = child + .stdin + .take() + .context("no stdin from opencode")? + .write_all(invocation.stdin.as_deref().unwrap_or("").as_bytes()); + if let Err(error) = written { + OpenCode.stop(&mut child); + return Err(error).context("could not write the check prompt to opencode"); + } + let output = check_output(&mut child)?; + let mut stream = OpenCode.stream(Path::new("."), "Reply with OK."); + for line in String::from_utf8_lossy(&output.stdout).lines() { + stream.condense(line); + } + let stream = OpenCode.read_after_exit(Path::new("."), stream); + if !output.status.success() || stream.failed() { + bail!( + "opencode refused the Model or Effort in its test call: {}", + stream + .error() + .map(String::from) + .unwrap_or_else(|| said(&output)) + ); + } + Ok(()) +} + +/// OpenCode errors appear as strings or typed objects in its event/export formats. +fn error_text(error: &serde_json::Value) -> Option<&str> { + error.as_str().or_else(|| error["message"].as_str()) +} + +/// Drain both pipes while watching for interruption, so a slow check cannot +/// strand its private server or commands before a Run has even started work. +fn check_output(child: &mut Child) -> Result { + fn read(mut pipe: impl Read + Send + 'static) -> thread::JoinHandle>> { + thread::spawn(move || { + let mut bytes = Vec::new(); + pipe.read_to_end(&mut bytes)?; + Ok(bytes) + }) + } + let stdout = read(child.stdout.take().context("no stdout from opencode")?); + let stderr = read(child.stderr.take().context("no stderr from opencode")?); + let status = loop { + if crate::interrupt::requested() { + OpenCode.stop(child); + let _ = stdout.join(); + let _ = stderr.join(); + bail!("interrupted"); + } + if let Some(status) = child + .try_wait() + .context("could not wait for opencode's test call")? + { + break status; + } + thread::sleep(Duration::from_millis(100)); + }; + Ok(Output { + status, + stdout: stdout + .join() + .map_err(|_| anyhow!("OpenCode's check stdout reader panicked"))??, + stderr: stderr + .join() + .map_err(|_| anyhow!("OpenCode's check stderr reader panicked"))??, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_resume_uses_the_streamed_session_id_with_every_flag_and_the_prompt_on_stdin() { + let choice = Choice { + harness: Harness::OpenCode, + model: Some("provider/model".into()), + effort: Some("high".into()), + chosen_by: super::super::ChosenBy::Command, + }; + let prompt = "/thirdshift-implement issue-url"; + let mut stream = OpenCode.stream(Path::new("."), prompt); + stream.condense(r#"{"type":"step_start","sessionID":"ses_eedb9657fffec0RAa52IqAvyM1","part":{"type":"step-start"}}"#); + let invocation = OpenCode.session(&choice, stream.session_id(), prompt); + assert_eq!( + invocation.args, + [ + "run", + "--standalone", + "--format", + "json", + "--auto", + "-m", + "provider/model#high", + "-s", + "ses_eedb9657fffec0RAa52IqAvyM1" + ] + ); + assert_eq!( + invocation.stdin.as_deref(), + Some("Load thirdshift-implement with your skill tool. issue-url") + ); + assert_eq!( + OpenCode.environment(), + [("OPENCODE_DISABLE_AUTOUPDATE", "1")] + ); + assert!(stream.killed_background_work().is_empty()); + } +} diff --git a/src/harness/opencode/export.rs b/src/harness/opencode/export.rs new file mode 100644 index 000000000..b44d83df5 --- /dev/null +++ b/src/harness/opencode/export.rs @@ -0,0 +1,214 @@ +//! Read OpenCode 2's standalone export, whose assistant content and usage +//! survive dropped stream events. Session totals also count the title call. +use super::OpenCode; +use crate::harness::Adapter; +use crate::progress::Stream; +use anyhow::{Result, anyhow}; +use serde_json::Value; +use std::path::Path; +use std::process::{Command, Stdio}; + +pub struct SessionExport { + message: Option, + summary: Option, + failure: Option, +} + +/// OpenCode records uncached input and visible output separately from cache +/// and reasoning. Combine them into totals while retaining each subtotal. +#[derive(Default)] +struct Usage { + input: u64, + cache_read: u64, + cache_write: u64, + output: u64, + reasoning: u64, + cost: Option, +} + +impl Usage { + fn add(&mut self, message: &Value) { + let tokens = &message["tokens"]; + self.input += tokens["input"].as_u64().unwrap_or(0); + self.cache_read += tokens["cache"]["read"].as_u64().unwrap_or(0); + self.cache_write += tokens["cache"]["write"].as_u64().unwrap_or(0); + self.output += tokens["output"].as_u64().unwrap_or(0); + self.reasoning += tokens["reasoning"].as_u64().unwrap_or(0); + if let Some(cost) = message["cost"].as_f64() { + *self.cost.get_or_insert(0.0) += cost; + } + } + fn summary(&self) -> String { + let input = self.input + self.cache_read + self.cache_write; + let output = self.output + self.reasoning; + let cost = self + .cost + .map(|cost| format!(", ${cost:.4}")) + .unwrap_or_default(); + format!( + "{input} input tokens ({} cache read, {} cache write), {output} output tokens ({} reasoning){cost}", + self.cache_read, self.cache_write, self.reasoning + ) + } +} + +impl SessionExport { + pub fn parse(text: &str) -> Result { + let export: Value = serde_json::from_str(text)?; + let outcome = export["info"]["outcome"] + .as_str() + .ok_or_else(|| anyhow!("no OpenCode outcome in session export"))?; + let messages = export["messages"] + .as_array() + .ok_or_else(|| anyhow!("no messages in OpenCode session export"))?; + let mut result = Self { + message: None, + summary: None, + failure: None, + }; + let mut usage: Option = None; + for message in messages + .iter() + .filter(|message| message["type"] == "assistant") + { + result.message = message["content"] + .as_array() + .and_then(|parts| parts.iter().rev().find(|part| part["type"] == "text")) + .and_then(|part| part["text"].as_str()) + .map(String::from); + if message["tokens"].is_object() { + usage.get_or_insert_default().add(message); + } + if let Some(error) = super::error_text(&message["error"]) { + result.failure = Some(error.into()); + } + } + result.summary = usage.as_ref().map(Usage::summary); + if outcome == "failed" { + result.failure = Some( + super::error_text(&export["info"]["error"]) + .map(String::from) + .or(result.failure) + .unwrap_or_else(|| "OpenCode's exported outcome is failed".into()), + ); + } else { + result.failure = None; + } + Ok(result) + } +} + +pub fn read_after_exit(worktree: &Path, stream: Box) -> Box { + let export = stream.session_id().and_then(|id| { + let output = Command::new(OpenCode.name()) + .args(["session", "export", "--standalone", id]) + .envs(OpenCode.environment().iter().copied()) + .current_dir(worktree) + .stdin(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + SessionExport::parse(&String::from_utf8_lossy(&output.stdout)).ok() + }); + Box::new(AfterExit { stream, export }) +} + +struct AfterExit { + stream: Box, + export: Option, +} +impl Stream for AfterExit { + fn condense(&mut self, raw: &str) -> Vec { + self.stream.condense(raw) + } + fn summary(&self) -> Option { + self.export + .as_ref() + .and_then(|export| export.summary.clone()) + } + fn final_message(&self) -> Option<&str> { + match &self.export { + Some(export) => export.message.as_deref(), + None => self.stream.final_message(), + } + } + fn session_id(&self) -> Option<&str> { + self.stream.session_id() + } + fn killed_background_work(&self) -> Vec<&str> { + if self.failed() { + Vec::new() + } else { + self.stream.killed_background_work() + } + } + fn failed(&self) -> bool { + self.stream.failed() + || self + .export + .as_ref() + .is_some_and(|export| export.failure.is_some()) + } + fn error(&self) -> Option<&str> { + self.stream.error().or_else(|| { + self.export + .as_ref() + .and_then(|export| export.failure.as_deref()) + }) + } + fn warnings(&self) -> Vec { + self.stream.warnings() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_last_assistant_text_and_summed_usage_exclude_the_title_call_and_user() { + // OpenCode 2.0.24's exported message format, with irrelevant fields removed. + let export = SessionExport::parse(r#"{ + "info":{"outcome":"succeeded","tokens":{"input":999999},"cost":9}, + "messages":[ + {"type":"user","text":"prompt","tokens":{"input":999999}}, + {"type":"assistant","tokens":{"input":31,"output":37,"reasoning":158,"cache":{"read":7840,"write":20}},"cost":0.004,"content":[{"type":"text","text":"earlier reply"}]}, + {"type":"assistant","tokens":{"input":39,"output":11,"reasoning":42,"cache":{"read":496,"write":7}},"cost":0.002,"content":[{"type":"text","text":"first part"},{"type":"reasoning","text":"private reasoning"},{"type":"text","text":"Architecture review idea: https://github.com/acme/widgets/issues/8"}]}, + {"type":"idle","outcome":"succeeded"} + ] + }"#).unwrap(); + assert_eq!( + export.message.as_deref(), + Some("Architecture review idea: https://github.com/acme/widgets/issues/8") + ); + assert_eq!( + export.summary.as_deref(), + Some( + "8433 input tokens (8336 cache read, 27 cache write), 248 output tokens (200 reasoning), $0.0060" + ) + ); + assert!(export.failure.is_none()); + } + + #[test] + fn a_failed_export_reports_the_assistants_error_when_the_session_has_none() { + let export = SessionExport::parse(r#"{"info":{"outcome":"failed"},"messages":[{"type":"assistant","content":[],"error":{"type":"provider.no-route","message":"Variant unavailable"}}]}"#).unwrap(); + assert_eq!(export.failure.as_deref(), Some("Variant unavailable")); + } + + #[test] + fn incomplete_or_malformed_exports_are_unreadable() { + for text in ["{", "{}", r#"{"info":{"outcome":"succeeded"}}"#] { + assert!(SessionExport::parse(text).is_err()); + } + } + + #[test] + fn an_assistant_without_text_does_not_reuse_an_earlier_reply() { + let export = SessionExport::parse(r#"{"info":{"outcome":"succeeded"},"messages":[{"type":"assistant","content":[{"type":"text","text":"earlier reply"}]},{"type":"assistant","content":[{"type":"tool","name":"bash"}]}]}"#).unwrap(); + assert!(export.message.is_none()); + assert!(export.summary.is_none()); + } +} diff --git a/src/harness/opencode/stream.rs b/src/harness/opencode/stream.rs new file mode 100644 index 000000000..58d767315 --- /dev/null +++ b/src/harness/opencode/stream.rs @@ -0,0 +1,155 @@ +//! OpenCode's JSONL progress; final usage is deliberately left to session export. +use crate::harness::skill_load::SkillLoad; +use crate::progress::{Stream, bash, shorten}; +use serde_json::Value; + +#[derive(Default)] +pub struct OpenCodeProgress { + session_id: Option, + started: bool, + message: Option, + failure: Option, + skill_load: SkillLoad, +} + +impl OpenCodeProgress { + pub fn for_prompt(prompt: &str) -> Self { + Self { + skill_load: SkillLoad::for_prompt(prompt), + ..Self::default() + } + } +} + +impl Stream for OpenCodeProgress { + fn condense(&mut self, raw: &str) -> Vec { + let Ok(event) = serde_json::from_str::(raw) else { + return Vec::new(); + }; + if let Some(id) = event["sessionID"].as_str().filter(|id| !id.is_empty()) { + self.session_id = Some(id.into()); + } + let part = &event["part"]; + match event["type"].as_str() { + Some("step_start") if !self.started => { + self.started = true; + vec!["session started".into()] + } + Some("text") => { + if let Some(text) = part["text"].as_str() { + self.message = Some(text.into()); + } + Vec::new() + } + Some("tool_use") => { + let tool = part["tool"].as_str().unwrap_or(""); + let state = &part["state"]; + if tool == "skill" { + let skill = state["input"]["id"].as_str().unwrap_or(""); + if state["status"] == "completed" { + self.skill_load.observed(skill); + } + vec![format!("skill {skill}")] + } else if tool == "bash" { + state["input"]["command"] + .as_str() + .map(|command| vec![bash(command)]) + .unwrap_or_default() + } else if tool.is_empty() { + Vec::new() + } else { + vec![shorten(tool)] + } + } + Some("step_finish") => vec!["step finished".into()], + Some("error") => { + let error = &event["error"]; + let message = super::error_text(error).unwrap_or("OpenCode's turn failed"); + self.failure = Some(if let Some(kind) = error["type"].as_str() { + format!("{kind}: {message}") + } else { + message.into() + }); + vec![format!( + "error: {}", + shorten(self.failure.as_deref().unwrap()) + )] + } + _ => Vec::new(), + } + } + fn summary(&self) -> Option { + None + } + fn final_message(&self) -> Option<&str> { + self.message.as_deref() + } + fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + fn killed_background_work(&self) -> Vec<&str> { + // OpenCode's stream has no documented event proving background work + // was killed at session end. Tool cancellation alone is not evidence. + Vec::new() + } + fn failed(&self) -> bool { + self.failure.is_some() + } + fn error(&self) -> Option<&str> { + self.failure.as_deref() + } + fn warnings(&self) -> Vec { + self.skill_load.warnings() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_dropped_last_step_keeps_the_last_text_without_trusting_stream_usage() { + let mut stream = OpenCodeProgress::for_prompt("/thirdshift-implement issue-url"); + for line in [ + r#"{"type":"step_start","sessionID":"s1","part":{}}"#, + r#"{"type":"tool_use","sessionID":"s1","part":{"tool":"skill","state":{"status":"completed","input":{"id":"thirdshift-implement"}}}}"#, + r#"{"type":"text","sessionID":"s1","part":{"text":"earlier reply"}}"#, + r#"{"type":"step_finish","sessionID":"s1","part":{"tokens":{"input":1200,"output":300}}}"#, + r#"{"type":"text","sessionID":"s1","part":{"text":"last reply"}}"#, + "not JSON", + ] { + stream.condense(line); + } + assert_eq!(stream.final_message(), Some("last reply")); + assert_eq!(stream.session_id(), Some("s1")); + assert!(stream.warnings().is_empty()); + assert!(stream.summary().is_none()); + } + + #[test] + fn a_wrong_or_failed_skill_load_still_warns() { + let mut stream = OpenCodeProgress::for_prompt("/thirdshift-implement issue-url"); + for line in [ + r#"{"type":"tool_use","part":{"tool":"skill","state":{"status":"completed","input":{"id":"thirdshift-tdd"}}}}"#, + r#"{"type":"tool_use","part":{"tool":"skill","state":{"status":"error","input":{"id":"thirdshift-implement"}}}}"#, + ] { + stream.condense(line); + } + assert_eq!( + stream.warnings(), + ["warning: the session never loaded thirdshift-implement with its skill tool"] + ); + assert!(stream.session_id().is_none()); + } + + #[test] + fn a_recorded_no_route_error_fails_and_names_the_model_or_effort_problem() { + let mut stream = OpenCodeProgress::default(); + stream.condense(r#"{"type":"error","sessionID":"s1","error":{"type":"provider.no-route","message":"Model unavailable: thirdshift-invalid-provider/invalid"}}"#); + assert!(stream.failed()); + assert_eq!( + stream.error(), + Some("provider.no-route: Model unavailable: thirdshift-invalid-provider/invalid") + ); + } +} diff --git a/src/harness/skill_load.rs b/src/harness/skill_load.rs new file mode 100644 index 000000000..6fe4ddf62 --- /dev/null +++ b/src/harness/skill_load.rs @@ -0,0 +1,38 @@ +//! Warning policy for Harnesses whose models load Factory skills with a tool. +#[derive(Default)] +pub struct SkillLoad { + expected: Option, + loaded: bool, +} + +impl SkillLoad { + pub fn for_prompt(prompt: &str) -> Self { + Self { + expected: prompt + .strip_prefix('/') + .and_then(|rest| rest.split_whitespace().next()) + .filter(|name| name.starts_with("thirdshift-")) + .map(String::from), + loaded: false, + } + } + + /// Adapters decode their own event format and report successful loads only. + pub fn observed(&mut self, name: &str) { + if self.expected.as_deref() == Some(name) { + self.loaded = true; + } + } + + pub fn warnings(&self) -> Vec { + self.expected + .as_ref() + .filter(|_| !self.loaded) + .map(|skill| { + vec![format!( + "warning: the session never loaded {skill} with its skill tool" + )] + }) + .unwrap_or_default() + } +} diff --git a/src/main.rs b/src/main.rs index 1eb3afded..0c4d1a145 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,7 +73,7 @@ merge, --no-merge, --email, --no-email, base-fix, --no-base-fix, parallel (o --parallel ), harness , model and effort (or --harness, --model and --effort) go before or after the Issue URL, in any order. -harness (claude, codex, agy, grok or muse), model and effort choose the Harness every session runs on, and its +harness (claude, codex, agy, grok, muse or opencode), model and effort choose the Harness every session runs on, and its Model and Effort, for each Ticket's Run and a Base fix too. For each, the command wins, then the User config, then the default: claude, with its own Model and Effort. A Model and Effort in the User config come from the chosen Harness's own section, so harness claude over a codex @@ -103,6 +103,13 @@ session and Resume uses -p --dangerously-skip-permissions --output-format stream null stdin and AGY_CLI_DISABLE_AUTO_UPDATE=true; a Resume uses --conversation . agy reads AGENTS.md and GEMINI.md, falling back through an excluded GEMINI.md link to root CLAUDE.md when neither exists. It does not read ~/.claude/. +On opencode (OpenCode), the Model id comes from your providers as provider/model; +Effort is its #variant suffix and needs a Model. A minimal standalone call checks both. +Every session, check and Resume uses run --standalone --format json --auto with the prompt +on stdin and OPENCODE_DISABLE_AUTOUPDATE=1; a Resume adds -s . +Skills load through the skill tool. OpenCode reads AGENTS.md, never CLAUDE.md or ~/.claude/; +thirdshift supplies an excluded AGENTS.md link to root CLAUDE.md when needed. Session export +supplies the last assistant text, usage and outcome even when the last stream event is lost. --email sends one Run notification when the Run ends, whatever the outcome: ready for review, merged, failed or interrupted. --email
sends it to
; a word diff --git a/src/setup.rs b/src/setup.rs index cec600489..04a087552 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -373,6 +373,8 @@ pub(crate) trait Outside { fn grok_catalog(&mut self) -> Result; /// Check Muse's settings against its cache, or with its minimal test call. fn muse_check(&mut self, chosen: &ModelAndEffort) -> Result; + /// Check OpenCode with its minimal standalone test call. + fn opencode_check(&mut self, chosen: &ModelAndEffort) -> Result<()>; /// Read the Credentials, as strictly as a Run does: where a Resend API /// key is found, if anywhere. fn find_key(&mut self) -> Result>; @@ -477,6 +479,10 @@ impl Outside for OnMachine<'_> { crate::harness::muse::check_model_and_effort(chosen) } + fn opencode_check(&mut self, chosen: &ModelAndEffort) -> Result<()> { + crate::harness::opencode::check_model_and_effort(chosen) + } + fn find_key(&mut self) -> Result> { let credentials = Credentials::read()?; let found = credentials.lookup().map(|key| key.source); @@ -846,6 +852,10 @@ mod scripted { .map_err(|error| anyhow!("{error}")) } + fn opencode_check(&mut self, _chosen: &ModelAndEffort) -> Result<()> { + Ok(()) + } + fn find_key(&mut self) -> Result> { self.key.clone().map_err(|error| anyhow!("{error}")) } @@ -1160,7 +1170,7 @@ to = \"me@example.com\" # my inbox assert_eq!( outside.prompts(), [ - "Harness for every Run's sessions, claude or codex or agy or grok or muse [claude]: ", + "Harness for every Run's sessions, claude or codex or agy or grok or muse or opencode [claude]: ", "Model for claude [claude's own default]: ", "Effort for claude [claude's own default]: ", "Every Run a Merge run? [y/N] ", @@ -1271,6 +1281,10 @@ effort = \"\" [harness.muse] model = \"\" effort = \"\" + +[harness.opencode] +model = \"\" +effort = \"\" "; let mut outside = Scripted { user_config: Some(mine.to_string()), @@ -1300,7 +1314,7 @@ effort = \"\" assert_eq!( outside.prompts(), [ - "Harness for every Run's sessions, claude or codex or agy or grok or muse [claude]: ", + "Harness for every Run's sessions, claude or codex or agy or grok or muse or opencode [claude]: ", "Model for claude, - for claude's own default [opus]: ", "Effort for claude, - for claude's own default [high]: ", "Every Run a Merge run? [Y/n] ", @@ -1802,11 +1816,11 @@ always = false # quiet, please assert_eq!( outside.prompts()[0], - "Harness for every Run's sessions, claude or codex or agy or grok or muse [claude]: " + "Harness for every Run's sessions, claude or codex or agy or grok or muse or opencode [claude]: " ); let config = table(&outside); assert_eq!(config["harness"]["default"].as_str(), Some("claude")); - for harness in ["claude", "codex", "agy", "grok", "muse"] { + for harness in ["claude", "codex", "agy", "grok", "muse", "opencode"] { for key in ["model", "effort"] { assert_eq!(config["harness"][harness][key].as_str(), Some("")); } @@ -1860,7 +1874,7 @@ always = false # quiet, please outside.said(), [ "No Harness is installed here, so the harness settings stay as they are; \ - install claude or codex or agy or grok or muse, then rerun `thirdshift setup`." + install claude or codex or agy or grok or muse or opencode, then rerun `thirdshift setup`." ] ); let config = table(&outside); diff --git a/tests/fakes.rs b/tests/fakes.rs index d1a5d79c9..f2ea69807 100644 --- a/tests/fakes.rs +++ b/tests/fakes.rs @@ -1,4 +1,4 @@ -//! The fake `gh`, `claude`, `codex`, `agy`, `grok` and `muse` the end-to-end tests put first on +//! The fake `gh`, `claude`, `codex`, `agy`, `grok`, `muse` and `opencode` the end-to-end tests put first on //! PATH: one executable, which acts as whichever its name says. See //! `fakes/gh.rs`, `fakes/claude.rs` and `fakes/codex.rs` for what each does, //! and `support/fakes.rs` for how the harness builds it. @@ -27,6 +27,8 @@ mod grok; mod json; #[path = "fakes/muse.rs"] mod muse; +#[path = "fakes/opencode.rs"] +mod opencode; use std::fs::{self, File}; use std::io::Write; @@ -120,6 +122,7 @@ fn main() { "agy" => agy::main(args.collect()), "grok" => grok::main(args.collect()), "muse" => muse::main(args.collect()), + "opencode" => opencode::main(args.collect()), "detached-command" => { let command = args.next().expect("no detached command"); let mut command = Command::new(command); diff --git a/tests/fakes/muse.rs b/tests/fakes/muse.rs index 3c3654d34..ed2d0d806 100644 --- a/tests/fakes/muse.rs +++ b/tests/fakes/muse.rs @@ -28,7 +28,7 @@ pub(crate) fn stdin_is_null() -> bool { /// Catch SIGINT and SIGTERM, doing nothing on either, so the fake outlasts /// them until its script ends. A caught signal, unlike an ignored one, is /// back to its default in the script, which can trap it. -fn outlast_interrupts() { +pub(crate) fn outlast_interrupts() { // From the C library, which the standard library links: SIGINT and // SIGTERM are 2 and 15 on Linux and macOS alike. unsafe extern "C" { @@ -42,7 +42,7 @@ fn outlast_interrupts() { } /// Print one line of the stream. -fn emit(event: Json) { +pub(crate) fn emit(event: Json) { println!("{event}"); std::io::stdout().flush().unwrap(); } diff --git a/tests/fakes/opencode.rs b/tests/fakes/opencode.rs new file mode 100644 index 000000000..610042f84 --- /dev/null +++ b/tests/fakes/opencode.rs @@ -0,0 +1,252 @@ +//! OpenCode's JSONL stream and standalone session export, with stdin prompts. +use crate::claude::{beside_skills_snapshot, exit_code, git_here, script_for, skills_snapshot}; +use crate::json::{Array, Json, Null, number, object, string}; +use crate::muse::{emit, outlast_interrupts}; +use std::fs; +use std::io::Read; +use std::process::Command; + +pub fn main(argv: Vec) { + let record_path = crate::env_path("FAKE_OPENCODE_RECORD"); + let export_path = |id: &str| record_path.with_extension(format!("{id}.export.json")); + if argv.starts_with(&["session".into(), "export".into()]) { + crate::append_record( + &record_path.with_extension("exports.json"), + object([ + ("argv", Array(argv.iter().map(string).collect())), + ( + "no_auto_update", + string(std::env::var("OPENCODE_DISABLE_AUTOUPDATE").unwrap_or_default()), + ), + ]), + ); + if std::env::var("FAKE_OPENCODE_NO_EXPORT").is_ok() { + crate::exit(1); + } + if std::env::var("FAKE_OPENCODE_CORRUPT_EXPORT").is_ok() { + println!("truncated {{"); + crate::exit(0); + } + println!( + "{}", + fs::read_to_string(export_path(argv.last().unwrap())).unwrap() + ); + crate::exit(0); + } + assert_eq!(argv[0], "run"); + assert!(argv.iter().any(|arg| arg == "--standalone")); + let mut prompt = String::new(); + std::io::stdin().read_to_string(&mut prompt).unwrap(); + let record = object([ + ("argv", Array(argv.iter().map(string).collect())), + ("prompt", string(&prompt)), + ( + "no_auto_update", + string(std::env::var("OPENCODE_DISABLE_AUTOUPDATE").unwrap_or_default()), + ), + ]); + if prompt == "Reply with OK." { + crate::append_record(&record_path.with_extension("checks.json"), record); + if argv + .iter() + .any(|arg| arg.contains("bad-model") || arg.contains("#bogus")) + { + emit(object([ + ("type", string("error")), + ( + "error", + object([ + ("type", string("provider.no-route")), + ("message", string("Model or variant unavailable")), + ]), + ), + ])); + crate::exit(1); + } + if let Ok(script) = std::env::var("FAKE_OPENCODE_CHECK_SCRIPT") { + let status = Command::new("bash").arg("-e").arg(script).status().unwrap(); + if !status.success() { + crate::exit(exit_code(status)); + } + } + event("check-session", "text", object([("text", string("OK"))])); + fs::write( + export_path("check-session"), + object([ + ( + "info", + object([( + "outcome", + string( + if std::env::var("FAKE_OPENCODE_CHECK_EXPORT_FAILED").is_ok() { + "failed" + } else { + "succeeded" + }, + ), + )]), + ), + ("messages", Array(vec![])), + ]) + .dump(), + ) + .unwrap(); + crate::exit(0); + } + let (status, _) = git_here(&["status", "--porcelain"]); + let cwd = std::env::current_dir().unwrap(); + let mut record = record; + record.set("cwd", string(cwd.to_str().unwrap())); + record.set("git_status", string(status)); + record.set("skill_files", skills_snapshot(".agents/skills")); + record.set("beside_skills", beside_skills_snapshot(".agents/skills")); + record.set( + "agents_link", + fs::read_link("AGENTS.md") + .ok() + .map(|path| string(path.to_str().unwrap())) + .unwrap_or(Null), + ); + record.set( + "agents_text", + fs::read_to_string("AGENTS.md") + .ok() + .map(string) + .unwrap_or(Null), + ); + let lock = crate::lock_beside(&record_path); + let records = crate::append_record(&record_path, record); + let records = records.items(); + let script = script_for(records); + drop(lock); + let session = records.len(); + let id = argv + .windows(2) + .find(|pair| pair[0] == "-s") + .map(|pair| pair[1].clone()) + .unwrap_or_else(|| format!("fake-opencode-{session}")); + outlast_interrupts(); + event(&id, "step_start", object([])); + if let Some(rest) = prompt.strip_prefix("Load ") { + let skill = rest.split_whitespace().next().unwrap(); + if std::env::var("FAKE_OPENCODE_SKIP_SKILL").is_err() { + event( + &id, + "tool_use", + object([ + ("tool", string("skill")), + ( + "state", + object([ + ("status", string("completed")), + ("input", object([("id", string(skill))])), + ]), + ), + ]), + ); + } + } + let final_message = format!("{}.final-message.{session}", record_path.display()); + let error = format!("{}.error.{session}", record_path.display()); + let status = Command::new("bash") + .arg("-e") + .arg(script) + .env("FAKE_CLAUDE_FINAL_MESSAGE", &final_message) + .env("FAKE_OPENCODE_ERROR", &error) + .status() + .unwrap(); + let code = exit_code(status); + let message = fs::read_to_string(&final_message).unwrap_or_else(|_| "done".into()); + event(&id, "text", object([("text", string("earlier reply"))])); + event(&id, "text", object([("text", string(&message))])); + let failed = code != 0 || std::env::var("FAKE_OPENCODE_EXPORT_FAILED").is_ok(); + let error = fs::read_to_string(error).unwrap_or_else(|_| "OpenCode's turn failed".into()); + let assistant = |text: &str| { + object([ + ("type", string("assistant")), + ( + "tokens", + object([ + ("input", number(1200)), + ("output", number(300)), + ("reasoning", number(100)), + ( + "cache", + object([("read", number(200)), ("write", number(0))]), + ), + ]), + ), + ("cost", number(0)), + ( + "content", + Array(vec![object([ + ("type", string("text")), + ("text", string(text)), + ])]), + ), + ]) + }; + fs::write( + export_path(&id), + object([ + ( + "info", + object([ + ( + "outcome", + string(if failed { "failed" } else { "succeeded" }), + ), + ("tokens", object([("input", number(999999))])), + ("error", object([("message", string(&error))])), + ]), + ), + ( + "messages", + Array(vec![ + object([("type", string("user")), ("text", string("prompt"))]), + assistant("earlier reply"), + assistant(&message), + ]), + ), + ]) + .dump(), + ) + .unwrap(); + // Alternate complete and dropped closing events, as real OpenCode does. + if session.is_multiple_of(2) { + event( + &id, + "step_finish", + object([ + ("reason", string("stop")), + ( + "tokens", + object([("input", number(1200)), ("output", number(300))]), + ), + ]), + ); + } + if code != 0 { + emit(object([ + ("type", string("error")), + ("sessionID", string(&id)), + ("error", object([("message", string(error))])), + ])); + } + crate::exit(code); +} + +fn event(id: &str, kind: &str, part: Json) { + emit(object([ + ("type", string(kind)), + ( + "sessionID", + string(if std::env::var("FAKE_OPENCODE_NO_SESSION_ID").is_ok() { + "" + } else { + id + }), + ), + ("part", part), + ])); +} diff --git a/tests/opencode_sessions.rs b/tests/opencode_sessions.rs new file mode 100644 index 000000000..0e4f5dddc --- /dev/null +++ b/tests/opencode_sessions.rs @@ -0,0 +1,388 @@ +//! OpenCode Commands through the scenario harness. +mod support; +use serde_json::{Value, json}; +use support::{FACTORY_SKILLS, Scenario}; + +const AGENT_OPENS_PR: &str = r#" +echo feature > feature.txt +git add feature.txt +git commit -q -m 'Add feature' +gh pr create --base main --head issue-7 --title 'Add feature' --body 'Closes #7' +"#; + +fn records(scenario: &Scenario, extension: &str) -> Vec { + std::fs::read_to_string(scenario.path(&format!("opencode-calls.{extension}"))) + .map(|text| serde_json::from_str(&text).unwrap()) + .unwrap_or_default() +} + +#[test] +fn opencode_runs_standalone_with_stdin_skills_and_usage_despite_a_dropped_last_step() { + let scenario = Scenario::new(); + scenario.agent_does_for(7, AGENT_OPENS_PR); + let result = scenario.run(&[ + "harness", + "opencode", + "model", + "provider/MiMo-V2.6-Pro", + "effort", + "high", + &scenario.issue_url(7), + ]); + assert_eq!(result.code, Some(0), "{}", result.stderr); + let calls = records(&scenario, "json"); + assert_eq!(calls.len(), 1); + assert_eq!( + calls[0]["argv"], + json!([ + "run", + "--standalone", + "--format", + "json", + "--auto", + "-m", + "provider/MiMo-V2.6-Pro#high" + ]) + ); + assert_eq!(calls[0]["no_auto_update"], "1"); + assert_eq!(calls[0]["git_status"], ""); + assert!( + calls[0]["prompt"] + .as_str() + .unwrap() + .starts_with("Load thirdshift-implement with your skill tool. ") + ); + for skill in FACTORY_SKILLS { + assert!( + calls[0]["skill_files"][format!("{skill}/SKILL.md")].is_string(), + "{skill}" + ); + } + assert!( + result.stderr.contains( + "2800 input tokens (400 cache read, 0 cache write), 800 output tokens (200 reasoning)" + ), + "{}", + result.stderr + ); + assert!(!result.stderr.contains("warning:"), "{}", result.stderr); + let checks = records(&scenario, "checks.json"); + assert_eq!(checks[0]["argv"], calls[0]["argv"]); + assert_eq!(checks[0]["prompt"], "Reply with OK."); + assert_eq!(checks[0]["no_auto_update"], "1"); + let exports = records(&scenario, "exports.json"); + let exports = exports + .iter() + .filter(|call| call["argv"][3] != "check-session") + .collect::>(); + assert_eq!( + exports[0]["argv"], + json!(["session", "export", "--standalone", "fake-opencode-1"]) + ); + assert_eq!(exports[0]["no_auto_update"], "1"); + assert!( + scenario.gh_state()["prs"][0]["body"] + .as_str() + .unwrap() + .contains("Built with opencode · provider/MiMo-V2.6-Pro · high") + ); +} + +#[test] +fn user_config_selects_opencode_and_command_effort_overrides_its_variant() { + let scenario = Scenario::new(); + scenario.user_config_is("[harness]\ndefault = \"opencode\"\n[harness.opencode]\nmodel = \"provider/model#low\"\neffort = \"low\"\n"); + scenario.agent_does_for(7, AGENT_OPENS_PR); + let result = scenario.run(&["effort", "high", &scenario.issue_url(7)]); + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!( + records(&scenario, "json")[0]["argv"][6], + "provider/model#high" + ); +} + +#[test] +fn bad_models_and_efforts_fail_before_the_claim_worktree_and_command_log() { + for (model, effort) in [("provider/bad-model", "high"), ("provider/model", "bogus")] { + let scenario = Scenario::new(); + let result = scenario.run(&[ + "harness", + "opencode", + "model", + model, + "effort", + effort, + &scenario.issue_url(7), + ]); + assert_eq!(result.code, Some(1), "{}", result.stderr); + assert!( + result.stderr.contains("Model or Effort"), + "{}", + result.stderr + ); + assert!( + result.stderr.contains("provider.no-route"), + "{}", + result.stderr + ); + assert!(records(&scenario, "json").is_empty()); + assert_eq!(records(&scenario, "checks.json").len(), 1); + assert!(scenario.issue_labels(7).is_empty()); + assert_eq!(scenario.entries("work"), ["widgets"]); + assert!( + !scenario + .path("home/.thirdshift/logs/acme/widgets/commands") + .exists() + ); + } +} + +#[test] +fn an_effort_without_a_model_explains_how_to_select_the_route() { + let scenario = Scenario::new(); + let result = scenario.run(&[ + "harness", + "opencode", + "effort", + "high", + &scenario.issue_url(7), + ]); + assert_eq!(result.code, Some(1), "{}", result.stderr); + assert!( + result.stderr.contains("set model / too"), + "{}", + result.stderr + ); + assert!(records(&scenario, "checks.json").is_empty()); +} + +#[test] +fn claude_only_instructions_are_linked_as_agents_and_kept_out_of_git() { + for existing in [false, true] { + let scenario = Scenario::new(); + std::fs::write(scenario.launch_dir().join("CLAUDE.md"), "claude rules\n").unwrap(); + if existing { + std::fs::write(scenario.launch_dir().join("AGENTS.md"), "own rules\n").unwrap(); + } + scenario.launch_git(&["add", "."]); + scenario.launch_git(&["commit", "-m", "Project instructions"]); + scenario.launch_git(&["push", "origin", "main"]); + scenario.agent_does_for(7, AGENT_OPENS_PR); + let result = scenario.run(&["harness", "opencode", &scenario.issue_url(7)]); + assert_eq!(result.code, Some(0), "{}", result.stderr); + let calls = records(&scenario, "json"); + assert_eq!( + calls[0]["agents_link"], + if existing { + Value::Null + } else { + json!("CLAUDE.md") + } + ); + assert_eq!( + calls[0]["agents_text"], + if existing { + "own rules\n" + } else { + "claude rules\n" + } + ); + assert_eq!(calls[0]["git_status"], ""); + assert_eq!( + scenario.origin_file("issue-7", "AGENTS.md"), + existing.then(|| "own rules\n".to_string()) + ); + } +} + +#[test] +fn an_exported_failed_outcome_fails_even_when_the_cli_exits_zero() { + let scenario = Scenario::new(); + scenario.agent_does_for( + 7, + "echo 'route became unavailable' > \"$FAKE_OPENCODE_ERROR\"", + ); + let result = scenario.run_with_env( + &["harness", "opencode", &scenario.issue_url(7)], + &[("FAKE_OPENCODE_EXPORT_FAILED", "1")], + ); + assert_eq!(result.code, Some(1), "{}", result.stderr); + assert!( + result + .stderr + .contains("opencode's turn failed: route became unavailable"), + "{}", + result.stderr + ); + assert_eq!(records(&scenario, "json").len(), 1); +} + +#[test] +fn a_failed_turn_in_the_stream_or_nonzero_exit_fails_with_opencodes_error() { + for script in [ + "echo 'provider unavailable' > \"$FAKE_OPENCODE_ERROR\"\nexit 1", + r#"echo '{"type":"error","sessionID":"fake-opencode-1","error":{"message":"provider unavailable"}}'"#, + ] { + let scenario = Scenario::new(); + scenario.agent_does_for(7, script); + let result = scenario.run(&["harness", "opencode", &scenario.issue_url(7)]); + assert_eq!(result.code, Some(1), "{}", result.stderr); + assert!( + result.stderr.contains("provider unavailable"), + "{}", + result.stderr + ); + assert_eq!(records(&scenario, "json").len(), 1); + } +} + +#[test] +fn architecture_reviews_receive_the_last_reply_with_or_without_the_export() { + for setting in [ + None, + Some("FAKE_OPENCODE_NO_EXPORT"), + Some("FAKE_OPENCODE_CORRUPT_EXPORT"), + ] { + let scenario = Scenario::new(); + scenario.repo_has_labels(&["needs-triage", "ready-for-agent", "architecture"]); + scenario.agent_does_in_session( + 1, + r#" +url=$(gh issue create --title 'An idea' --body 'The idea' --label needs-triage) +printf 'Architecture review idea: %s\n' "$url" > "$FAKE_CLAUDE_FINAL_MESSAGE" +"#, + ); + let env = setting + .map(|setting| vec![(setting, "1")]) + .unwrap_or_default(); + let result = scenario.run_with_env(&["architect", "harness", "opencode"], &env); + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!( + result.stdout.trim(), + "https://github.com/acme/widgets/issues/8" + ); + assert_eq!( + result.stderr.contains("2800 input tokens"), + setting.is_none() + ); + } +} + +#[test] +fn a_missing_skill_warns_in_the_progress_and_command_log_without_retrying() { + let scenario = Scenario::new(); + scenario.agent_does_for(7, AGENT_OPENS_PR); + let result = scenario.run_with_env( + &["harness", "opencode", &scenario.issue_url(7)], + &[("FAKE_OPENCODE_SKIP_SKILL", "1")], + ); + assert_eq!(result.code, Some(0), "{}", result.stderr); + let warning = "warning: the session never loaded thirdshift-implement with its skill tool"; + assert_eq!(result.stderr.matches(warning).count(), 1); + assert_eq!(records(&scenario, "json").len(), 1); + let dir = "home/.thirdshift/logs/acme/widgets/commands/issue"; + let name = scenario.entries(dir).pop().unwrap(); + let log = std::fs::read_to_string(scenario.path(&format!("{dir}/{name}"))).unwrap(); + assert_eq!(log.matches(warning).count(), 1); +} + +#[test] +fn setup_proposes_no_model_and_checks_the_answer_standalone() { + let scenario = Scenario::new(); + let result = scenario.run_on_terminal( + &["setup"], + &[], + &[ + ("Harness for every Run", "opencode"), + ( + "Model for opencode [opencode's own default]", + "provider/model", + ), + ("Effort for opencode", "high"), + ("Every Run a Merge run", ""), + ("Every Run first fast-forwards", ""), + ("Run notifications", ""), + ], + ); + assert_eq!(result.code, Some(0), "{}", result.stderr); + let config: toml::Table = result.user_config.unwrap().parse().unwrap(); + assert_eq!(config["harness"]["default"].as_str(), Some("opencode")); + assert_eq!( + config["harness"]["opencode"]["model"].as_str(), + Some("provider/model") + ); + assert_eq!( + config["harness"]["opencode"]["effort"].as_str(), + Some("high") + ); + let checks = records(&scenario, "checks.json"); + assert_eq!( + checks[0]["argv"], + json!([ + "run", + "--standalone", + "--format", + "json", + "--auto", + "-m", + "provider/model#high" + ]) + ); + assert_eq!(checks[0]["prompt"], "Reply with OK."); + assert_eq!(checks[0]["no_auto_update"], "1"); +} + +#[test] +fn an_exported_failed_check_stops_before_any_work_even_with_exit_zero() { + let scenario = Scenario::new(); + let result = scenario.run_with_env( + &["harness", "opencode", &scenario.issue_url(7)], + &[("FAKE_OPENCODE_CHECK_EXPORT_FAILED", "1")], + ); + assert_eq!(result.code, Some(1), "{}", result.stderr); + assert!( + result.stderr.contains("exported outcome is failed"), + "{}", + result.stderr + ); + assert!(scenario.issue_labels(7).is_empty()); + assert!(records(&scenario, "json").is_empty()); +} + +#[test] +fn without_a_stream_session_id_no_resume_or_export_id_is_invented() { + let scenario = Scenario::new(); + scenario.agent_does_for(7, AGENT_OPENS_PR); + let result = scenario.run_with_env( + &["harness", "opencode", &scenario.issue_url(7)], + &[("FAKE_OPENCODE_NO_SESSION_ID", "1")], + ); + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!(records(&scenario, "json").len(), 1); + assert!(records(&scenario, "exports.json").is_empty()); + assert!(!result.stderr.contains("input tokens")); +} + +#[test] +fn setup_retries_a_refused_model_and_effort_with_the_same_preflight_check() { + let scenario = Scenario::new(); + let result = scenario.run_on_terminal( + &["setup"], + &[], + &[ + ("Harness for every Run", "opencode"), + ("Model for opencode", "provider/bad-model"), + ("Effort for opencode", "high"), + ("Model for opencode", "provider/model"), + ("Effort for opencode", "low"), + ("Every Run a Merge run", ""), + ("Every Run first fast-forwards", ""), + ("Run notifications", ""), + ], + ); + assert_eq!(result.code, Some(0), "{}", result.stderr); + let checks = records(&scenario, "checks.json"); + assert_eq!(checks.len(), 2); + assert_eq!(checks[0]["argv"][6], "provider/bad-model#high"); + assert_eq!(checks[1]["argv"][6], "provider/model#low"); +} diff --git a/tests/process_tree.rs b/tests/process_tree.rs index a18325259..2c14c2e83 100644 --- a/tests/process_tree.rs +++ b/tests/process_tree.rs @@ -37,45 +37,66 @@ impl Drop for DetachedCommand { #[test] fn interrupting_a_claude_run_stops_a_command_in_its_own_session() { - assert_detached_command_stopped("claude"); + assert_detached_command_stopped("claude", false); } #[test] fn interrupting_a_codex_run_stops_a_command_in_its_own_session() { - assert_detached_command_stopped("codex"); + assert_detached_command_stopped("codex", false); } #[test] fn interrupting_an_agy_run_stops_a_command_in_its_own_session() { - assert_detached_command_stopped("agy"); + assert_detached_command_stopped("agy", false); +} + +#[test] +fn interrupting_an_opencode_run_stops_a_command_in_its_own_session() { + assert_detached_command_stopped("opencode", false); +} + +#[test] +fn interrupting_an_opencode_check_stops_its_whole_process_tree_before_work() { + assert_detached_command_stopped("opencode", true); } #[test] fn interrupting_a_grok_run_stops_a_command_in_its_own_session() { - assert_detached_command_stopped("grok"); + assert_detached_command_stopped("grok", false); } #[test] fn interrupting_a_muse_run_stops_a_command_in_its_own_session() { - assert_detached_command_stopped("muse"); + assert_detached_command_stopped("muse", false); } -fn assert_detached_command_stopped(harness: &str) { +fn assert_detached_command_stopped(harness: &str, checking: bool) { let scenario = Scenario::new(); let pid = scenario.path("detached-pid"); + let ending = if checking { + "sleep 9" + } else { + "while :; do sleep 0.1; done" + }; scenario.agent_does(&format!( r#"detached-command bash -c 'trap "" INT TERM; echo $$ > "{pid}"; exec sleep 120' >/dev/null 2>&1 & while ! test -s "{pid}"; do sleep 0.01; done touch "{started}" -while :; do sleep 0.1; done +{ending} "#, pid = pid.display(), started = scenario.path("agent-started").display(), )); + let script = scenario.path("claude-script.sh"); + let env = if checking { + vec![("FAKE_OPENCODE_CHECK_SCRIPT", script.to_str().unwrap())] + } else { + Vec::new() + }; let mut held = scenario.run_until( &["harness", harness, &scenario.issue_url(7)], - &[], + &env, "agent-started", ); let command = DetachedCommand(fs::read_to_string(pid).unwrap().trim().parse().unwrap()); @@ -85,6 +106,10 @@ while :; do sleep 0.1; done let result = held.finish(); assert_eq!(result.code, Some(1), "stderr: {}", result.stderr); + if checking { + assert!(scenario.issue_labels(7).is_empty()); + assert_eq!(scenario.entries("work"), ["widgets"]); + } assert!( interrupted_at.elapsed() < Duration::from_secs(8), "a session that exited on its first signal incurred another grace period" diff --git a/tests/setup.rs b/tests/setup.rs index 7d17bb962..26650112b 100644 --- a/tests/setup.rs +++ b/tests/setup.rs @@ -55,7 +55,8 @@ fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() { "[harness.codex]", "[harness.agy]", "[harness.grok]", - "[harness.muse]" + "[harness.muse]", + "[harness.opencode]" ], "{text}" ); @@ -73,7 +74,7 @@ fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() { assert_eq!(config["spec"]["parallel"].as_integer(), Some(3)); assert_eq!(config["pickup"]["limit"].as_integer(), Some(3)); assert_eq!(config["harness"]["default"].as_str(), Some("claude")); - for harness in ["claude", "codex", "agy", "grok", "muse"] { + for harness in ["claude", "codex", "agy", "grok", "muse", "opencode"] { for key in ["model", "effort"] { assert_eq!(config["harness"][harness][key].as_str(), Some(""), "{text}"); } diff --git a/tests/support/fakes.rs b/tests/support/fakes.rs index f19d4d8af..6cecdb2ca 100644 --- a/tests/support/fakes.rs +++ b/tests/support/fakes.rs @@ -1,4 +1,4 @@ -//! The fake `gh`, `claude`, `codex`, `agy`, `grok` and `muse`, built from +//! The fake `gh`, `claude`, `codex`, `agy`, `grok`, `muse` and `opencode`, built from //! `tests/fakes.rs` with plain `rustc` the first time a test needs them and //! kept in Cargo's scratch directory for tests. They aren't a binary target, //! so neither the crate nor a release ships them. @@ -11,20 +11,21 @@ use std::sync::OnceLock; /// The fakes' sources. They name the built executable, and including them /// makes Cargo rebuild the tests when they change. -const SOURCES: [&str; 10] = [ +const SOURCES: [&str; 11] = [ include_str!("../fakes.rs"), include_str!("../fakes/gh.rs"), include_str!("../fakes/claude.rs"), include_str!("../fakes/codex.rs"), include_str!("../fakes/agy.rs"), include_str!("../fakes/muse.rs"), + include_str!("../fakes/opencode.rs"), include_str!("../fakes/json.rs"), include_str!("../fakes/grok.rs"), include_str!("../fixtures/grok-models.json"), include_str!("../fixtures/grok-models.txt"), ]; -/// Put the fake `gh`, `claude`, `codex`, `agy`, `grok` and `muse` in `bin`. +/// Put the fake `gh`, `claude`, `codex`, `agy`, `grok`, `muse` and `opencode` in `bin`. pub fn install(bin: &Path) { for name in [ "gh", @@ -33,6 +34,7 @@ pub fn install(bin: &Path) { "agy", "grok", "muse", + "opencode", "detached-command", ] { std::os::unix::fs::symlink(executable(), bin.join(name)).unwrap(); diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 1375d01f6..89d17dfe7 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -607,6 +607,7 @@ test -f {root}/{COPY_REPLACED} .env("FAKE_AGY_CHECK_RECORD", self.path("agy-checks.json")) .env("FAKE_GROK_RECORD", self.path("grok-calls.json")) .env("FAKE_MUSE_RECORD", self.path("muse-calls.json")) + .env("FAKE_OPENCODE_RECORD", self.path("opencode-calls.json")) .env("FAKE_GH_RECORD", self.path("gh-calls.json")) // Seconds of waiting for CI become milliseconds. Each poll starts // the fake gh; at 100ms the grace period holds about three reads,