From 3ef66d50ae1fa8b8fc964a855981d78fac91b328 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 6 Oct 2026 12:39:00 -0400 Subject: [PATCH 1/3] Add the agy Harness and installed-only Setup (#436) --- CONTEXT.md | 2 +- README.md | 19 +- ...rness-runs-unattended-and-fully-trusted.md | 27 ++ src/args.rs | 4 +- src/asks.rs | 1 + src/config.rs | 9 +- src/harness.rs | 6 + src/harness/adapter.rs | 5 +- src/harness/agy.rs | 115 ++++++ src/harness/agy/catalog.rs | 221 +++++++++++ src/harness/agy/stream.rs | 188 +++++++++ src/harness/instructions.rs | 40 ++ src/main.rs | 6 + src/setup.rs | 101 ++++- src/setup/questions.rs | 33 +- src/skills.rs | 4 +- tests/agy_sessions.rs | 364 ++++++++++++++++++ tests/fakes.rs | 3 + tests/fakes/agy.rs | 153 ++++++++ tests/fakes/codex.rs | 2 +- tests/setup.rs | 5 +- tests/support/fakes.rs | 5 +- tests/support/mod.rs | 17 + 23 files changed, 1281 insertions(+), 49 deletions(-) create mode 100644 docs/adr/0013-every-harness-runs-unattended-and-fully-trusted.md create mode 100644 src/harness/agy.rs create mode 100644 src/harness/agy/catalog.rs create mode 100644 src/harness/agy/stream.rs create mode 100644 src/harness/instructions.rs create mode 100644 tests/agy_sessions.rs create mode 100644 tests/fakes/agy.rs diff --git a/CONTEXT.md b/CONTEXT.md index 3db2d76f9..bc699c467 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -31,7 +31,7 @@ The message thirdshift starts or resumes an agent session with. It carries the * _Avoid_: instructions, system prompt **Harness**: -The headless agent CLI that runs every agent session of a **Command**: Claude Code (`claude`) or Codex (`codex`). One per Command, its child Runs and every **Resume** and **Repair** included. Chosen by the command, else the **User config**, else Claude Code. +The headless agent CLI that runs every agent session of a **Command**: Claude Code (`claude`), Codex (`codex`), Antigravity CLI (`agy`), Grok Build (`grok`), Muse Code (`muse`) or OpenCode (`opencode`). One per Command, its child Runs and every **Resume** and **Repair** included. Chosen by the command, else the **User config**, else Claude Code. _Avoid_: agent, provider, backend **Model**: diff --git a/README.md b/README.md index a5ffc0e51..2530095a3 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ The user account that runs thirdshift needs: - **`claude`** (Claude Code), logged in. - **`codex`** (Codex CLI), logged in, only for `harness codex`. +- **`agy`** (Google Antigravity CLI), logged in, only for `harness agy`. - **`gh`** (GitHub CLI), logged in. - **`git`** with a global `user.name` and `user.email`, and credentials that can push to the repository (`gh auth setup-git` makes git use `gh`'s login). The agents commit as this identity; without it, an agent may borrow the author of the last commit. @@ -213,6 +214,10 @@ effort = "high" # how hard that Model reasons; blank for Claude Code's own defa [harness.codex] model = "" effort = "" + +[harness.agy] +model = "gemini-3.8-flash" +effort = "medium" ``` `thirdshift setup` writes this file for you, listing every setting at its default so the file itself shows what can be changed: @@ -245,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 or codex; default claude +default = "claude" # the Harness every Run's sessions run on, claude, codex or agy; default claude [harness.claude] model = "" # the Model Claude Code's sessions run on; default blank, for Claude Code's own @@ -254,11 +259,15 @@ effort = "" # how hard that Model reasons; default blank, for Claude Code's ow [harness.codex] model = "" # the Model Codex's sessions run on; default blank, for Codex's own effort = "" # how hard that Model reasons; default blank, for Codex's own + +[harness.agy] +model = "" # the Model Antigravity CLI's sessions run on; default blank, for agy's own +effort = "" # how hard that Model reasons; default blank, for agy'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 both, each marked `(not installed)` where its CLI isn't on `PATH`. A marked Harness, or a name that isn't one, is refused and asked again. The default is the first one installed, `claude` when both are, and `codex` when only it is. 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. The other Harness's section is left as it is. 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 whose CLI is installed on `PATH`. An unavailable Harness, or a name that isn't one, is refused and asked again. The default is the configured one when installed, otherwise the first installed in `claude`, `codex`, `agy` order, so `claude` when it is installed. 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. 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`) @@ -299,6 +308,12 @@ For each of the three, the command wins, then the [User config](#user-config), t With `harness codex`, every session runs `codex exec --json --dangerously-bypass-approvals-and-sandbox` in the Run's worktree, with stdin set to null, and with `-c project_doc_fallback_filenames=["CLAUDE.md"]`, so Codex reads `CLAUDE.md` where a repository has no `AGENTS.md`. Codex's sandbox is off: on a stock Ubuntu machine it can't start, and commits and pushes from a worktree write outside it, so the worktree is the only boundary, as with Claude's auto mode ([ADR-0012](docs/adr/0012-factory-skills-linked-into-the-worktree-codex-unsandboxed.md)). The Factory skills are linked into the worktree's `.agents/skills/`, kept out of git with `/.agents/skills/thirdshift-*` in `.git/info/exclude`, and each prompt's first line loads its skill as `$thirdshift-`. `codex exec` kills what the agent left running as it exits, so a session whose turn ends with a command or a sub-agent call still running (started, never completed) gets a Resume, as on Claude: `codex exec … resume `, with the Model, Effort, bypass flag and `CLAUDE.md` fallback given again, as Codex keeps none of them. An interrupt sends a Codex session SIGINT, the only signal Codex stops cleanly on, before the SIGTERM and SIGKILL every session gets, each 10 seconds apart. Progress lines come from Codex's `item.*` events, the session-ended line gives its token totals, as Codex reports no turns or cost, and the final message is the last `agent_message`. A session whose turn fails (`turn.failed`), or that exits non-zero, fails the Run with Codex's error in the cause; an `error` event Codex retries does not. Each Codex session adds a `trust_level = "trusted"` entry for the repository to `~/.codex/config.toml`, and your `~/.claude/` instructions and hooks don't reach it. +With `harness agy`, every session runs Google **Antigravity CLI** as `agy -p --dangerously-skip-permissions --output-format stream-json`, with `--model` and `--effort` when set, stdin null, and `AGY_CLI_DISABLE_AUTO_UPDATE=true` (agy ignores `=1`). The sandbox is opt-in and is never enabled, so unattended sessions can write the shared `.git` ([ADR-0013](docs/adr/0013-every-harness-runs-unattended-and-fully-trusted.md)). Factory skills are linked into `.agents/skills/`, excluded from git, and loaded by the `/thirdshift-` first line. agy reads project `AGENTS.md`, `GEMINI.md` and `.agents/rules/`, and user instructions under `~/.gemini/`; it does not read `~/.claude/`. When root `CLAUDE.md` exists and neither `AGENTS.md` nor `GEMINI.md` does, thirdshift adds a `GEMINI.md` link to `CLAUDE.md` and excludes it through `.git/info/exclude`; existing instruction files always win. + +agy's `init` supplies the conversation id and `step_update` supplies tool progress. The final `result` supplies the final message, success/error status, error and cumulative usage (including on Resume). `ERROR` or a nonzero exit fails the session with agy's error. A Resume uses `--conversation ` with the same flags, Model, Effort and environment; an interrupt sends SIGTERM, then SIGKILL after the grace period. Its start-up can be slow and variable: the research measured 8–95 seconds. No speed assumption or backend retry is made. + +Before any work on agy, a named Model or Effort is checked against the free `agy models` catalog, which lists IDs, labels and effort-suffixed variants. IDs, display labels and bare aliases are matched regardless of case, as are Efforts. `model Gemini-3.8-Flash effort Medium` becomes `--model gemini-3.8-flash --effort medium`; `model gemini-3.8-flash-medium` already carries its Effort. A bare alias requires an Effort; a Model or Effort the catalog lacks fails naming valid choices. This check uses null stdin and disables self-update too, without a model turn. + Before any work, where an Origin match failure would stop it, and so before the Claim, the worktree and any Command log, a command checks what it chose: the Harness's CLI must be on `PATH`, else it fails naming the CLI and the setting that chose it; and on Claude a named Model gets a minimal test call, `claude -p --model ` with the Effort, if any, which must succeed, else the command fails with what Claude said. No test call is made when no Model is named. On Codex a named Model and Effort are checked against `codex debug models`, which costs no tokens: each matches regardless of case, a Model by its slug or its display name, and becomes the name Codex takes, so `model GPT-6.1-Sol effort Max` reaches Codex as `gpt-6.1-sol` and `max`. An unknown Model, or an Effort that Model doesn't support (with no Model, one no Model supports), fails naming the valid choices. What the command records is what the check settled on. A Pickup run or an Architect run makes these checks only once it has decided not to skip, and a Spec run makes them once for all its Tickets. 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. diff --git a/docs/adr/0013-every-harness-runs-unattended-and-fully-trusted.md b/docs/adr/0013-every-harness-runs-unattended-and-fully-trusted.md new file mode 100644 index 000000000..2672bdabf --- /dev/null +++ b/docs/adr/0013-every-harness-runs-unattended-and-fully-trusted.md @@ -0,0 +1,27 @@ +# Every Harness runs unattended and fully trusted in the worktree + +Sessions run with no human to answer an approval prompt or a trust question. Their commits and pushes write to the main repository's shared `.git`, outside the worktree. So every **Harness** runs its sessions, and every Resume of one, with approvals off, its sandbox off, and the worktree trusted. Each Harness carries the flags and environment that do this. They are fixed, and no user setting changes them: + +| Harness | Flags and environment | +|---|---| +| Claude Code | `--permission-mode auto` | +| Codex | `--dangerously-bypass-approvals-and-sandbox` | +| agy | `--dangerously-skip-permissions`, never `--sandbox` | +| Grok Build | `--always-approve`, `GROK_FOLDER_TRUST=0` | +| Muse Code | `--yolo` | +| OpenCode | `--auto`, `--standalone` | + +The worktree is the only boundary. This generalises the Codex part of ADR 0012. Sources: `docs/research/harness-candidates.md`, `docs/research/codex-headless-harness.md`. + +## Considered Options + +- **Each CLI's own sandbox.** Rejected. On stock Ubuntu (`kernel.apparmor_restrict_unprivileged_userns = 1`), bubblewrap can't start, so every sandboxed command fails under Codex and Muse. The sandboxes of agy and Grok make the main checkout's `.git` read-only, which blocks commits. A sandboxed mode for machines where it works would be a separate issue. +- **A one-time `grok --trust` in each repository's main checkout**, done or documented by Setup. Rejected. A repository nobody had trusted would run its sessions without `AGENTS.md` or the Factory skills, and nothing would fail. A scheduled Pickup run is exactly the case where nobody would notice. +- **Using the CLI's settings for approval and trust**, such as a user config that already sets always-approve. Rejected. A session would then behave differently on every machine. Passing the flags each time makes them explicit. +- **OpenCode through the user's shared background service** (its default). Rejected. A SIGTERM leaves the turn and its shell command running in the service, and thirdshift doesn't control that service's version or environment. `--standalone` keeps the server in the session's process group, and costs 2–3 s per session. + +## Consequences + +- A Harness's self-update is turned off in each session's environment too (`MUSE_NO_AUTO_UPDATE=1`, `GROK_DISABLE_AUTOUPDATER=1`, `AGY_CLI_DISABLE_AUTO_UPDATE=true`, `OPENCODE_DISABLE_AUTOUPDATE=1`). An update in the middle of a Run could replace the binary a Resume needs. agy ignores `=1`; only `=true` works. +- Each Harness reads its own instruction file and falls back to the other. thirdshift adds the fallback where a CLI lacks one: a `GEMINI.md` link for agy, and an `AGENTS.md` link for OpenCode, both to `CLAUDE.md` at the worktree root and kept out of git. Grok reads both files when both exist, and thirdshift leaves that as it is. +- Instructions in the user's `~/.claude/` reach only the Harnesses that read them: Claude, Grok and Muse. They don't reach Codex, agy or OpenCode. diff --git a/src/args.rs b/src/args.rs index b445828cf..66272b5b1 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", + "harness must be followed by claude or codex or agy", ), ( vec![URL, "harness", "gemini"], - "harness must be followed by claude or codex, not gemini", + "harness must be followed by claude or codex or agy, not gemini", ), ( vec!["--harness", "claude", URL, "harness", "codex"], diff --git a/src/asks.rs b/src/asks.rs index 74f5f68af..6eeafe446 100644 --- a/src/asks.rs +++ b/src/asks.rs @@ -262,6 +262,7 @@ mod tests { default: Some(Harness::Codex), claude: model_and_effort("opus", "high"), codex: model_and_effort("gpt-6.1-sol", "max"), + ..Default::default() }, ..no_settings() } diff --git a/src/config.rs b/src/config.rs index 5bbd324d6..8c80d6a3f 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 or codex; default claude +default = "claude" # the Harness every Run's sessions run on, claude or codex or agy; default claude [harness.claude] model = "" # the Model Claude Code's sessions run on; default blank, for Claude Code's own @@ -396,6 +396,10 @@ effort = "" # how hard that Model reasons; default blank, for Claude Code's ow [harness.codex] model = "" # the Model Codex's sessions run on; default blank, for Codex's own effort = "" # how hard that Model reasons; default blank, for Codex's own + +[harness.agy] +model = "" # the Model Antigravity CLI's sessions run on; default blank, for agy's own +effort = "" # how hard that Model reasons; default blank, for agy's own "#; /// The line `DEFAULTS` holds for `email.to`, which has no default. @@ -577,6 +581,7 @@ mod tests { model: Some("gpt-6.1-sol".to_string()), effort: Some("max".to_string()), }, + ..Default::default() } ); assert_eq!(parse("").unwrap().harness, harness::Settings::default()); @@ -587,7 +592,7 @@ mod tests { for (text, error) in [ ( "[harness]\ndefault = \"gemini\"\n", - "harness.default must be claude or codex, not \"gemini\"", + "harness.default must be claude or codex or agy, not \"gemini\"", ), ( "[harness]\ndefault = true\n", diff --git a/src/harness.rs b/src/harness.rs index fc047bb8d..eb1906dba 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -10,8 +10,10 @@ use std::process::Output; use anyhow::{Result, bail}; mod adapter; +pub(crate) mod agy; pub(crate) mod claude; pub(crate) mod codex; +mod instructions; pub use adapter::{Adapter, Invocation}; pub use codex::Catalog; @@ -21,6 +23,7 @@ pub use codex::Catalog; pub enum Harness { Claude, Codex, + Agy, } impl Harness { @@ -90,6 +93,8 @@ pub struct Settings { pub claude: ModelAndEffort, /// `[harness.codex]`. pub codex: ModelAndEffort, + /// `[harness.agy]`. + pub agy: ModelAndEffort, } impl Settings { @@ -280,6 +285,7 @@ mod tests { default: Some(Harness::Codex), claude: set("opus", "high"), codex: set("gpt-6.1-sol", "max"), + ..Default::default() } } diff --git a/src/harness/adapter.rs b/src/harness/adapter.rs index 93ba94b36..643db2b1e 100644 --- a/src/harness/adapter.rs +++ b/src/harness/adapter.rs @@ -4,7 +4,7 @@ use std::path::Path; use anyhow::Result; -use super::{Choice, Harness, ModelAndEffort, Settings, claude, codex}; +use super::{Choice, Harness, ModelAndEffort, Settings, agy, claude, codex}; use crate::progress::Stream; use crate::setup::Outside; @@ -84,4 +84,5 @@ impl SkillLoading { } /// Registration comes from the adapters, including Setup's order. -pub const REGISTERED: [(Harness, &dyn Adapter); 2] = [claude::REGISTRATION, codex::REGISTRATION]; +pub const REGISTERED: [(Harness, &dyn Adapter); 3] = + [claude::REGISTRATION, codex::REGISTRATION, agy::REGISTRATION]; diff --git a/src/harness/agy.rs b/src/harness/agy.rs new file mode 100644 index 000000000..7a83e3ce0 --- /dev/null +++ b/src/harness/agy.rs @@ -0,0 +1,115 @@ +//! Antigravity CLI: launch, free Model catalog, Setup and stream protocol. +mod catalog; +pub(crate) mod stream; +pub use catalog::Catalog; + +use super::adapter::{Adapter, Invocation, SkillLoading}; +use super::{Choice, Harness, ModelAndEffort, Settings}; +use crate::progress::{self, Stream}; +use crate::setup::{Outside, questions::ask_setting}; +use anyhow::Result; +use std::path::Path; + +pub struct Agy; +pub const REGISTRATION: (Harness, &dyn Adapter) = (Harness::Agy, &Agy); + +impl Adapter for Agy { + fn name(&self) -> &'static str { + "agy" + } + fn product_name(&self) -> &'static str { + "Antigravity CLI" + } + fn project_skills(&self) -> &'static str { + ".agents/skills" + } + fn skill_loading(&self) -> SkillLoading { + SkillLoading::Slash + } + fn stop_signals(&self) -> &'static [libc::c_int] { + &[libc::SIGTERM] + } + fn settings<'a>(&self, settings: &'a Settings) -> &'a ModelAndEffort { + &settings.agy + } + fn settings_mut<'a>(&self, settings: &'a mut Settings) -> &'a mut ModelAndEffort { + &mut settings.agy + } + fn environment(&self) -> &'static [(&'static str, &'static str)] { + &[("AGY_CLI_DISABLE_AUTO_UPDATE", "true")] + } + fn session(&self, choice: &Choice, resume: Option<&str>, prompt: &str) -> Invocation { + let mut args: Vec = [ + "-p", + "--dangerously-skip-permissions", + "--output-format", + "stream-json", + ] + .map(String::from) + .to_vec(); + if let Some(model) = &choice.model { + args.extend(["--model".to_string(), model.clone()]); + } + if let Some(effort) = &choice.effort { + args.extend(["--effort".to_string(), effort.clone()]); + } + if let Some(id) = resume { + args.extend(["--conversation".to_string(), id.to_string()]); + } + args.push(self.skill_loading().prompt(prompt)); + Invocation { args, stdin: None } + } + fn check(&self, choice: &mut Choice) -> Result<()> { + if choice.model.is_none() && choice.effort.is_none() { + return Ok(()); + } + progress::step("checking the Model and Effort against agy models"); + let settled = Catalog::read()?.settle(choice.model.as_deref(), choice.effort.as_deref())?; + choice.model = settled.model; + choice.effort = settled.effort; + Ok(()) + } + fn ask_settings( + &self, + outside: &mut dyn Outside, + current: &ModelAndEffort, + ) -> Result> { + let catalog = match outside.agy_catalog() { + Ok(catalog) => catalog, + Err(error) => { + outside.say(format!("{error:#}\nThe harness settings stay as they are; rerun `thirdshift setup` once agy models works.")); + return Ok(None); + } + }; + outside.say(format!( + "{}'s Models: {}", + self.product_name(), + catalog.listed().join(", ") + )); + let model = loop { + let model = ask_setting(outside, "Model", Harness::Agy, current.model.as_deref())?; + match catalog.model_name(model.as_deref()) { + Ok(model) => break model, + Err(error) => outside.say(format!("{error:#}")), + } + }; + outside.say(format!( + "Efforts {} supports: {}", + model.as_deref().unwrap_or("agy"), + catalog.efforts(model.as_deref()).join(", ") + )); + loop { + let effort = ask_setting(outside, "Effort", Harness::Agy, current.effort.as_deref())?; + match catalog.settle(model.as_deref(), effort.as_deref()) { + Ok(settled) => return Ok(Some(settled)), + Err(error) => outside.say(format!("{error:#}")), + } + } + } + fn link_instruction_fallback(&self, worktree: &Path) -> Result<()> { + super::instructions::link_fallback(worktree, "GEMINI.md", &["AGENTS.md"]) + } + fn stream(&self, _worktree: &Path) -> Box { + Box::new(stream::AgyProgress::default()) + } +} diff --git a/src/harness/agy/catalog.rs b/src/harness/agy/catalog.rs new file mode 100644 index 000000000..edae5d7d0 --- /dev/null +++ b/src/harness/agy/catalog.rs @@ -0,0 +1,221 @@ +//! agy models lists model IDs and labels, with supported Efforts encoded +//! in ID suffixes. Bare model aliases require an explicit Effort. +use super::Agy; +use crate::harness::{Adapter, ModelAndEffort, said}; +use anyhow::{Context, Result, bail}; +use std::process::{Command, Stdio}; + +#[derive(Debug)] +struct Model { + id: String, + label: String, +} + +#[derive(Debug)] +pub struct Catalog { + models: Vec, +} + +/// Effort suffixes agy knows, in increasing order. +const EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"]; + +fn alias(id: &str) -> (&str, Option<&str>) { + id.rsplit_once('-') + .filter(|(_, suffix)| EFFORTS.contains(suffix)) + .map_or((id, None), |(base, suffix)| (base, Some(suffix))) +} + +impl Catalog { + pub fn read() -> Result { + let output = Command::new(Agy.name()) + .arg("models") + .envs(Agy.environment().iter().copied()) + .stdin(Stdio::null()) + .output() + .context("could not run agy models to read Antigravity CLI's Models")?; + if !output.status.success() { + bail!( + "agy models failed, so Antigravity CLI's Models can't be read: {}", + said(&output) + ); + } + Self::parse(&String::from_utf8_lossy(&output.stdout)) + } + + pub fn parse(text: &str) -> Result { + let models: Vec = text + .lines() + .filter_map(|line| { + let (id, label) = line.trim().split_once('\t')?; + (!id.is_empty() && !label.trim().is_empty()).then(|| Model { + id: id.to_string(), + label: label.trim().to_string(), + }) + }) + .collect(); + if models.is_empty() { + bail!("agy models printed no catalog of Models"); + } + Ok(Self { models }) + } + + pub fn listed(&self) -> Vec { + self.models + .iter() + .map(|model| format!("{} ({})", model.id, model.label)) + .collect() + } + + /// Canonical ID, label or bare alias, matched regardless of case. + pub fn model_name(&self, name: Option<&str>) -> Result> { + let Some(name) = name else { + return Ok(None); + }; + for model in &self.models { + if model.id.eq_ignore_ascii_case(name) || model.label.eq_ignore_ascii_case(name) { + return Ok(Some(model.id.clone())); + } + } + if let Some(base) = self + .models + .iter() + .map(|model| alias(&model.id).0) + .find(|base| base.eq_ignore_ascii_case(name)) + { + return Ok(Some(base.to_string())); + } + bail!( + "the Model {name} is not in Antigravity CLI's catalog: choose one of {}", + self.models + .iter() + .map(|model| model.id.as_str()) + .collect::>() + .join(", ") + ); + } + + pub fn efforts(&self, model: Option<&str>) -> Vec<&str> { + EFFORTS + .into_iter() + .filter(|effort| { + self.models.iter().any(|candidate| { + let (base, suffix) = alias(&candidate.id); + suffix == Some(*effort) && model.is_none_or(|model| alias(model).0 == base) + }) + }) + .collect() + } + + pub fn settle(&self, model: Option<&str>, effort: Option<&str>) -> Result { + let model = self.model_name(model)?; + let efforts = self.efforts(model.as_deref()); + let settled_effort = match effort { + Some(effort) => Some( + *efforts + .iter() + .find(|valid| valid.eq_ignore_ascii_case(effort)) + .with_context(|| { + format!( + "the Effort {effort} is not one {} supports: choose one of {}", + model.as_deref().unwrap_or("any Antigravity CLI Model"), + efforts.join(", ") + ) + })?, + ), + None => None, + }; + if let Some(model) = &model + && !self.models.iter().any(|candidate| candidate.id == *model) + && settled_effort.is_none() + { + bail!( + "the Antigravity CLI Model {model} requires an Effort: choose one of {}", + efforts.join(", ") + ); + } + Ok(ModelAndEffort { + model, + effort: settled_effort.map(String::from), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + const MODELS: &str = "Fetching available models...\ngemini-3.8-flash-high\tGemini 3.8 Flash (High)\ngemini-3.8-flash-medium\tGemini 3.8 Flash (Medium)\ngemini-3.8-flash-low\tGemini 3.8 Flash (Low)\ngemini-3.1-pro-high\tGemini 3.1 Pro (High)\ngemini-3.1-pro-low\tGemini 3.1 Pro (Low)\ngpt-oss-120b-medium\tGPT-OSS 120B (Medium)\n"; + + #[test] + fn ids_labels_and_aliases_settle_regardless_of_case() { + let catalog = Catalog::parse(MODELS).unwrap(); + for name in ["GEMINI-3.8-FLASH-HIGH", "Gemini 3.8 Flash (HIGH)"] { + assert_eq!( + catalog.settle(Some(name), None).unwrap().model.as_deref(), + Some("gemini-3.8-flash-high") + ); + } + let choice = catalog + .settle(Some("Gemini-3.8-Flash"), Some("Medium")) + .unwrap(); + assert_eq!(choice.model.as_deref(), Some("gemini-3.8-flash")); + assert_eq!(choice.effort.as_deref(), Some("medium")); + assert_eq!( + catalog + .settle(Some("GPT-OSS-120B"), Some("MEDIUM")) + .unwrap() + .model + .as_deref(), + Some("gpt-oss-120b") + ); + } + + #[test] + fn effort_choices_follow_the_model_family_and_missing_effort_is_caught() { + let catalog = Catalog::parse(MODELS).unwrap(); + assert_eq!( + catalog.efforts(Some("gemini-3.1-pro-high")), + ["low", "high"] + ); + assert!( + catalog + .settle(Some("gemini-3.1-pro"), Some("medium")) + .unwrap_err() + .to_string() + .ends_with("choose one of low, high") + ); + assert!( + catalog + .settle(Some("gemini-3.8-flash"), None) + .unwrap_err() + .to_string() + .contains("requires an Effort") + ); + assert_eq!( + catalog + .settle(None, Some("HIGH")) + .unwrap() + .effort + .as_deref(), + Some("high") + ); + assert!( + catalog + .settle(Some("gemini-99"), None) + .unwrap_err() + .to_string() + .contains("gemini-3.8-flash-high") + ); + assert!(catalog.settle(None, Some("MAX")).is_err()); + } + + #[test] + fn an_empty_or_unreadable_catalog_fails_instead_of_accepting_names() { + for text in ["", "not a catalog", "\t\n", "model\t\n"] { + assert!(Catalog::parse(text).is_err()); + } + assert_eq!( + Catalog::parse(MODELS).unwrap().listed()[0], + "gemini-3.8-flash-high (Gemini 3.8 Flash (High))" + ); + } +} diff --git a/src/harness/agy/stream.rs b/src/harness/agy/stream.rs new file mode 100644 index 000000000..64cd70132 --- /dev/null +++ b/src/harness/agy/stream.rs @@ -0,0 +1,188 @@ +//! Antigravity CLI's event envelopes. Final text, failure and cumulative +//! usage come only from the final result, never concatenated text deltas. +use crate::progress::{Stream, bash, shorten}; +use serde_json::Value; +use std::collections::{BTreeMap, HashSet}; + +#[derive(Default)] +pub struct AgyProgress { + session_id: Option, + tools: HashSet, + result: Option, + running: BTreeMap, +} + +impl Stream for AgyProgress { + fn condense(&mut self, raw: &str) -> Vec { + let Ok(event) = serde_json::from_str::(raw) else { + return Vec::new(); + }; + match event["event"].as_str() { + Some("init") if self.session_id.is_none() => { + self.session_id = event["conversation_id"] + .as_str() + .filter(|id| !id.is_empty()) + .map(String::from); + vec!["session started".to_string()] + } + Some("step_update") => { + let step = &event["step_update"]; + if step["step_type"] != "tool" { + return Vec::new(); + } + let Some(index) = step["step_index"].as_u64() else { + return Vec::new(); + }; + if step["state"] == "DONE" { + self.running.remove(&index); + } + if !self.tools.insert(index) { + return Vec::new(); + } + let name = step["tool_name"].as_str().unwrap_or("tool"); + let input = &step["tool_info"]["parameters"]; + let description = match name { + "run_command" => bash(input["CommandLine"].as_str().unwrap_or("")), + _ => { + let detail = [ + "AbsolutePath", + "TargetFile", + "SearchPath", + "Query", + "Description", + ] + .iter() + .find_map(|key| input[key].as_str()); + detail.map_or_else( + || name.to_string(), + |detail| format!("{name} {}", shorten(detail)), + ) + } + }; + if step["state"] == "ACTIVE" && matches!(name, "run_command" | "invoke_subagent") { + self.running.insert( + index, + description + .strip_prefix("$ ") + .unwrap_or(&description) + .to_string(), + ); + } + vec![description] + } + Some("result") => { + self.result = Some(event["result"].clone()); + self.error() + .map(|error| format!("error: {}", shorten(error))) + .into_iter() + .collect() + } + _ => Vec::new(), + } + } + fn summary(&self) -> Option { + let result = self.result.as_ref()?; + let usage = &result["usage"]; + Some(format!( + "{} turns, {} input tokens ({} cached), {} output tokens ({} thinking), {} total tokens", + result["num_turns"].as_u64()?, + usage["input_tokens"].as_u64()?, + usage["cache_read_tokens"].as_u64().unwrap_or(0), + usage["output_tokens"].as_u64()?, + usage["thinking_tokens"].as_u64().unwrap_or(0), + usage["total_tokens"].as_u64()? + )) + } + fn final_message(&self) -> Option<&str> { + self.result.as_ref()?["response"].as_str() + } + fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + fn killed_background_work(&self) -> Vec<&str> { + if self.failed() { + return Vec::new(); + } + self.running.values().map(String::as_str).collect() + } + fn failed(&self) -> bool { + self.result + .as_ref() + .is_some_and(|result| result["status"] == "ERROR") + } + fn error(&self) -> Option<&str> { + self.result.as_ref()?["error"] + .as_str() + .filter(|error| !error.is_empty()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const INIT: &str = r#"{"event":"init","conversation_id":"recorded-conversation","init":{"cwd":"/work/widgets","permission_mode":"always-proceed"}}"#; + const TOOL: &str = r#"{"event":"step_update","step_update":{"step_index":2,"state":"ACTIVE","step_type":"tool","tool_name":"run_command","tool_info":{"name":"run_command","parameters":{"CommandLine":"cargo test"}}}}"#; + const RESULT: &str = r#"{"event":"result","result":{"conversation_id":"recorded-conversation","status":"SUCCESS","response":"Final reply only.","num_turns":2,"usage":{"input_tokens":12518,"output_tokens":29,"thinking_tokens":28,"cache_read_tokens":200,"total_tokens":12547}}}"#; + + #[test] + fn recorded_events_give_progress_id_final_reply_and_cumulative_usage() { + let mut stream = AgyProgress::default(); + assert_eq!(stream.condense(INIT), ["session started"]); + assert!(stream.condense(INIT).is_empty()); + assert_eq!(stream.condense(TOOL), ["$ cargo test"]); + assert!(stream.condense(TOOL).is_empty()); + stream.condense(&TOOL.replace("ACTIVE", "DONE")); + stream.condense(r#"{"event":"step_update","step_update":{"step_index":3,"step_type":"agent_response","text_delta":"Earlier reply."}}"#); + stream.condense(RESULT); + assert_eq!(stream.session_id(), Some("recorded-conversation")); + assert_eq!(stream.final_message(), Some("Final reply only.")); + assert_eq!( + stream.summary().as_deref(), + Some( + "2 turns, 12518 input tokens (200 cached), 29 output tokens (28 thinking), 12547 total tokens" + ) + ); + assert!(!stream.failed()); + assert_eq!(stream.error(), None); + assert!(stream.killed_background_work().is_empty()); + } + + #[test] + fn the_last_result_replaces_reply_usage_and_outcome() { + let mut stream = AgyProgress::default(); + stream.condense(RESULT); + let error = + r#"{"event":"result","result":{"status":"ERROR","error":"interrupted","response":""}}"#; + assert_eq!(stream.condense(error), ["error: interrupted"]); + assert!(stream.failed()); + assert_eq!(stream.error(), Some("interrupted")); + assert_eq!(stream.final_message(), Some("")); + assert_eq!(stream.summary(), None); + } + + #[test] + fn unfinished_commands_are_resume_work_only_on_success() { + let mut stream = AgyProgress::default(); + stream.condense(TOOL); + stream.condense(RESULT); + assert_eq!(stream.killed_background_work(), ["cargo test"]); + stream.condense(r#"{"event":"result","result":{"status":"ERROR","error":"interrupted"}}"#); + assert!(stream.killed_background_work().is_empty()); + } + + #[test] + fn unknown_and_malformed_lines_are_ignored() { + let mut stream = AgyProgress::default(); + for line in [ + "not JSON", + "{}", + r#"{"event":"future"}"#, + r#"{"event":"step_update","step_update":{}}"#, + ] { + assert!(stream.condense(line).is_empty()); + } + assert_eq!(stream.session_id(), None); + assert_eq!(stream.summary(), None); + } +} diff --git a/src/harness/instructions.rs b/src/harness/instructions.rs new file mode 100644 index 000000000..4159f465b --- /dev/null +++ b/src/harness/instructions.rs @@ -0,0 +1,40 @@ +//! Instruction-file fallback links shared by Harness adapters. Existing +//! files, including dangling links, always win; only root CLAUDE.md falls back. +use crate::{git::Git, skills}; +use anyhow::{Context, Result}; +use std::fs; +use std::io::ErrorKind; +use std::os::unix::fs::symlink; +use std::path::Path; + +/// Link `name` to root CLAUDE.md only when none of `name` and `preferred` +/// already exists. Exclude it before creating it, using the repository's +/// shared, locked exclude file. +pub(super) fn link_fallback(worktree: &Path, name: &str, preferred: &[&str]) -> Result<()> { + let exists = |name: &str| -> Result { + match fs::symlink_metadata(worktree.join(name)) { + Ok(_) => Ok(true), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(false), + Err(error) => Err(error) + .with_context(|| format!("could not inspect {}", worktree.join(name).display())), + } + }; + if exists(name)? { + return Ok(()); + } + for name in preferred { + if exists(name)? { + return Ok(()); + } + } + if !worktree.join("CLAUDE.md").is_file() { + return Ok(()); + } + skills::exclude(&Git::new(worktree), &format!("/{name}"))?; + symlink("CLAUDE.md", worktree.join(name)).with_context(|| { + format!( + "could not link {} to CLAUDE.md", + worktree.join(name).display() + ) + }) +} diff --git a/src/main.rs b/src/main.rs index bc55f95bf..aa93ecc43 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,6 +94,12 @@ Claim, the worktree and any Command log. Codex sessions run codex exec --json --dangerously-bypass-approvals-and-sandbox, with the skills in .agents/skills/. The Command log, the Activity log's start line, the pull request's body, as in Built with claude · opus · high, and the Run notification each name all three. +On agy (Antigravity CLI), agy models checks names without a turn, regardless of case; +a bare model alias needs an Effort, and effort-suffixed model IDs are accepted. Every +session and Resume uses -p --dangerously-skip-permissions --output-format stream-json, +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/. --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 d6804fcf3..48d3b6f6d 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -367,6 +367,8 @@ pub(crate) trait Outside { fn test_call(&mut self, chosen: &ModelAndEffort) -> Result<()>; /// Read Codex's catalog of Models. fn codex_catalog(&mut self) -> Result; + /// Read Antigravity CLI's free Model catalog. + fn agy_catalog(&mut self) -> 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>; @@ -459,6 +461,10 @@ impl Outside for OnMachine<'_> { Catalog::read() } + fn agy_catalog(&mut self) -> Result { + crate::harness::agy::Catalog::read() + } + fn find_key(&mut self) -> Result> { let credentials = Credentials::read()?; let found = credentials.lookup().map(|key| key.source); @@ -649,6 +655,7 @@ mod scripted { pub test_calls: VecDeque>, /// The catalog's JSON, or what reading it fails with. pub catalog: Result<&'static str, &'static str>, + pub agy_catalog: Result<&'static str, &'static str>, /// Where a key is found, or what reading the Credentials fails with. pub key: Result, &'static str>, pub github_email: Result, &'static str>, @@ -671,6 +678,9 @@ mod scripted { installed: Harness::ALL.to_vec(), test_calls: VecDeque::new(), catalog: Ok(CATALOG), + agy_catalog: Ok( + "gemini-3.8-flash-high\tGemini 3.8 Flash (High)\ngemini-3.8-flash-medium\tGemini 3.8 Flash (Medium)\ngemini-3.8-flash-low\tGemini 3.8 Flash (Low)\n", + ), key: Ok(None), github_email: Ok(None), git_email: Err("git config --global user.email: exit status 1"), @@ -793,6 +803,13 @@ mod scripted { } } + fn agy_catalog(&mut self) -> Result { + match self.agy_catalog { + Ok(text) => crate::harness::agy::Catalog::parse(text), + Err(error) => bail!("{error}"), + } + } + fn find_key(&mut self) -> Result> { self.key.clone().map_err(|error| anyhow!("{error}")) } @@ -1107,7 +1124,7 @@ to = \"me@example.com\" # my inbox assert_eq!( outside.prompts(), [ - "Harness for every Run's sessions, claude or codex [claude]: ", + "Harness for every Run's sessions, claude or codex or agy [claude]: ", "Model for claude [claude's own default]: ", "Effort for claude [claude's own default]: ", "Every Run a Merge run? [y/N] ", @@ -1206,6 +1223,10 @@ effort = \"high\" [harness.codex] model = \"gpt-6.1-sol\" effort = \"max\" + +[harness.agy] +model = \"\" +effort = \"\" "; let mut outside = Scripted { user_config: Some(mine.to_string()), @@ -1235,7 +1256,7 @@ effort = \"max\" assert_eq!( outside.prompts(), [ - "Harness for every Run's sessions, claude or codex [claude]: ", + "Harness for every Run's sessions, claude or codex or agy [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] ", @@ -1695,18 +1716,18 @@ always = false # quiet, please // The Harness, Model and Effort. #[test] - fn the_harness_question_lists_both_harnesses_and_defaults_to_claude() { + fn the_harness_question_lists_installed_harnesses_and_defaults_to_claude() { let mut outside = Scripted::answering(&ENTER_THROUGHOUT); setup(&mut outside).unwrap(); assert_eq!( outside.prompts()[0], - "Harness for every Run's sessions, claude or codex [claude]: " + "Harness for every Run's sessions, claude or codex or agy [claude]: " ); let config = table(&outside); assert_eq!(config["harness"]["default"].as_str(), Some("claude")); - for harness in ["claude", "codex"] { + for harness in ["claude", "codex", "agy"] { for key in ["model", "effort"] { assert_eq!(config["harness"][harness][key].as_str(), Some("")); } @@ -1731,13 +1752,13 @@ always = false # quiet, please assert_eq!( outside.prompts()[0], - "Harness for every Run's sessions, claude (not installed) or codex [codex]: " + "Harness for every Run's sessions, codex [codex]: " ); assert_eq!( outside.said()[..2], [ "claude is not installed: it isn't on PATH.", - "Choose claude or codex." + "Choose codex." ] ); assert_eq!( @@ -1747,7 +1768,7 @@ always = false # quiet, please } #[test] - fn with_neither_harness_installed_the_harness_settings_are_left_as_they_are() { + fn with_no_harness_installed_the_harness_settings_are_left_as_they_are() { let mut outside = Scripted { installed: Vec::new(), user_config: Some("[harness.claude]\nmodel = \"opus\"\n".to_string()), @@ -1759,9 +1780,8 @@ always = false # quiet, please assert_eq!( outside.said(), [ - "Harness for every Run's sessions: claude (not installed) or codex (not \ - installed). Sessions can run on neither here, so the harness settings stay as \ - they are; install claude or codex, then rerun `thirdshift setup`." + "No Harness is installed here, so the harness settings stay as they are; \ + install claude or codex or agy, then rerun `thirdshift setup`." ] ); let config = table(&outside); @@ -1791,6 +1811,65 @@ always = false # quiet, please } } + #[test] + fn agy_setup_checks_names_without_a_turn_and_lists_only_installed_harnesses() { + let mut outside = Scripted { + installed: vec![Harness::Agy], + ..Scripted::answering(&then_nothing_else(&[ + (HARNESS, ""), + ("Model for agy", "gemini-99"), + ("Model for agy", "Gemini-3.8-Flash"), + ("Effort for agy", "Max"), + ("Effort for agy", "Medium"), + ])) + }; + setup(&mut outside).unwrap(); + assert_eq!( + outside.prompts()[0], + "Harness for every Run's sessions, agy [agy]: " + ); + let config = table(&outside); + assert_eq!(config["harness"]["default"].as_str(), Some("agy")); + assert_eq!( + config["harness"]["agy"]["model"].as_str(), + Some("gemini-3.8-flash") + ); + assert_eq!(config["harness"]["agy"]["effort"].as_str(), Some("medium")); + assert!( + outside + .said() + .iter() + .any(|line| line.contains("the Model gemini-99 is not in")) + ); + assert!( + outside + .said() + .iter() + .any(|line| line.contains("the Effort Max is not one")) + ); + assert!(outside.test_calls().is_empty()); + } + + #[test] + fn an_unreadable_agy_catalog_keeps_existing_harness_settings() { + let mut outside = Scripted { + installed: vec![Harness::Agy], + agy_catalog: Err("not signed in"), + user_config: Some( + "[harness]\ndefault = \"agy\"\n[harness.agy]\nmodel = \"gemini-3.8-flash-high\"\n" + .to_string(), + ), + ..Scripted::answering(&then_nothing_else(&[(HARNESS, "")])) + }; + setup(&mut outside).unwrap(); + assert_eq!( + table(&outside)["harness"]["agy"]["model"].as_str(), + Some("gemini-3.8-flash-high") + ); + assert!(outside.said()[0].contains("not signed in")); + assert!(outside.said()[0].contains("once agy models works")); + } + #[test] fn claudes_model_and_effort_are_written_after_a_test_call_with_them() { let mut outside = Scripted::answering(&then_nothing_else(&[ diff --git a/src/setup/questions.rs b/src/setup/questions.rs index 4b0d489c2..481f0c19e 100644 --- a/src/setup/questions.rs +++ b/src/setup/questions.rs @@ -160,14 +160,10 @@ pub fn ask( }) } -/// Ask which Harness every Run's sessions run on, listing each, marked where -/// it isn't installed, and refusing that one; then its Model and Effort, -/// with those `current` sets for it as the defaults, checked as a Run -/// checks them, through the chosen adapter. The Harness's -/// default is the `current` one if it's installed, else the first one -/// installed, so `claude` when both are. With no -/// Harness to choose, or Codex chosen and its catalog unreadable, the answer -/// is `None`. +/// Ask which installed Harness every Run's sessions run on, then its +/// Model and Effort through the chosen adapter. Keep the configured default +/// when installed, otherwise prefer Claude, then the first installed. +/// With no Harness installed, leave the Harness settings as they are. fn ask_harness( outside: &mut impl Outside, current: &harness::Settings, @@ -176,17 +172,11 @@ fn ask_harness( .into_iter() .filter(|harness| outside.installed(*harness)) .collect(); - let harnesses: Vec = Harness::ALL + let harnesses = installed .iter() - .map(|harness| { - if installed.contains(harness) { - harness.name().to_string() - } else { - format!("{} (not installed)", harness.name()) - } - }) - .collect(); - let harnesses = harnesses.join(" or "); + .map(|harness| harness.name()) + .collect::>() + .join(" or "); let default = current .default .filter(|harness| installed.contains(harness)) @@ -194,9 +184,8 @@ fn ask_harness( let Some(default) = default else { let names = harness::names(); outside.say(format!( - "Harness for every Run's sessions: {harnesses}. Sessions can run on neither here, so \ - the harness settings stay as they are; install {names}, then rerun \ - `thirdshift setup`." + "No Harness is installed here, so the harness settings stay as they are; \ + install {names}, then rerun `thirdshift setup`." )); return Ok(None); }; @@ -207,7 +196,7 @@ fn ask_harness( Some(default.name()), )?; match Harness::named(&name) { - None => outside.say(format!("Choose {}.", harness::names())), + None => outside.say(format!("Choose {harnesses}.")), Some(harness) if !installed.contains(&harness) => { outside.say(format!("{name} is not installed: it isn't on PATH.")) } diff --git a/src/skills.rs b/src/skills.rs index 504534867..109c03ea0 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -81,7 +81,7 @@ fn written_out() -> Result { /// `git` works in, unless it is there already. The file is shared by every /// worktree, so the entry is left there, under a lock other Runs from the /// same Launch directory take too. -fn exclude(git: &Git, exclude: &str) -> Result<()> { +pub(crate) fn exclude(git: &Git, exclude: &str) -> Result<()> { let _lock = git.lock("thirdshift-exclude.lock")?; let info = git.common_dir()?.join("info"); let path = info.join("exclude"); @@ -106,5 +106,5 @@ fn exclude(git: &Git, exclude: &str) -> Result<()> { .append(true) .open(&path) .and_then(|mut file| writeln!(file, "{separator}{exclude}")) - .with_context(|| format!("could not add the Factory skills to {}", path.display())) + .with_context(|| format!("could not add an exclude pattern to {}", path.display())) } diff --git a/tests/agy_sessions.rs b/tests/agy_sessions.rs new file mode 100644 index 000000000..3a7386d40 --- /dev/null +++ b/tests/agy_sessions.rs @@ -0,0 +1,364 @@ +//! A Command's sessions on Antigravity CLI, through real Runs against fake agy. +mod support; + +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' +"#; + +#[test] +fn a_run_uses_agy_with_unattended_flags_skills_and_settled_model_and_effort() { + let scenario = Scenario::new(); + scenario.agent_does_for(7, &format!(r#"{AGENT_OPENS_PR} +echo '{{"event":"step_update","step_update":{{"step_index":2,"state":"ACTIVE","step_type":"tool","tool_name":"run_command","tool_info":{{"parameters":{{"CommandLine":"cargo test"}}}}}}}}' +echo '{{"event":"step_update","step_update":{{"step_index":2,"state":"DONE","step_type":"tool","tool_name":"run_command"}}}}' +"#)); + let result = scenario.run(&[ + "harness", + "agy", + "model", + "Gemini-3.8-Flash", + "effort", + "Medium", + &scenario.issue_url(7), + ]); + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert!(scenario.claude_calls().is_empty()); + let calls = scenario.agy_calls(); + assert_eq!(calls.len(), 1); + let call = &calls[0]; + let argv: Vec<&str> = call["argv"] + .as_array() + .unwrap() + .iter() + .map(|arg| arg.as_str().unwrap()) + .collect(); + assert_eq!( + &argv[..8], + [ + "-p", + "--dangerously-skip-permissions", + "--output-format", + "stream-json", + "--model", + "gemini-3.8-flash", + "--effort", + "medium" + ] + ); + assert!(!argv.contains(&"--sandbox")); + assert_eq!(call["stdin_null"], true); + assert_eq!(call["auto_update"], "true"); + assert_eq!(call["git_status"], ""); + assert!( + call["prompt"] + .as_str() + .unwrap() + .starts_with("/thirdshift-implement ") + ); + for skill in FACTORY_SKILLS { + assert!( + call["skill_files"][format!("{skill}/SKILL.md")] + .as_str() + .is_some(), + "{skill}" + ); + } + let checks = scenario.agy_checks(); + assert_eq!(checks.len(), 1); + assert_eq!(checks[0]["argv"], serde_json::json!(["models"])); + assert_eq!(checks[0]["auto_update"], "true"); + assert_eq!(checks[0]["stdin_null"], true); + assert!( + result + .stderr + .contains("sessions run on agy · gemini-3.8-flash · medium"), + "{}", + result.stderr + ); + for line in [ + "implement: session started", + "implement: $ cargo test", + "1200 input tokens (200 cached), 300 output tokens (150 thinking), 1500 total tokens", + ] { + assert!(result.stderr.contains(line), "{}", result.stderr); + } + assert!( + scenario.gh_state()["prs"][0]["body"] + .as_str() + .unwrap() + .contains("Built with agy · gemini-3.8-flash · medium") + ); + let sessions = "home/.thirdshift/logs/acme/widgets/sessions"; + let logs = scenario.entries(sessions); + let log = std::fs::read_to_string(scenario.path(&format!("{sessions}/{}", logs[0]))).unwrap(); + assert!(log.contains("\"event\": \"init\""), "{log}"); + assert!(log.contains("\"status\": \"SUCCESS\""), "{log}"); + assert!(log.contains("step_update"), "{log}"); +} + +#[test] +fn claude_only_instructions_are_linked_as_gemini_and_kept_out_of_git() { + let scenario = Scenario::new(); + std::fs::write(scenario.launch_dir().join("CLAUDE.md"), "project rules\n").unwrap(); + scenario.launch_git(&["add", "CLAUDE.md"]); + scenario.launch_git(&["commit", "-m", "Project rules"]); + scenario.launch_git(&["push", "origin", "main"]); + scenario.agent_does_for(7, AGENT_OPENS_PR); + let result = scenario.run(&["harness", "agy", &scenario.issue_url(7)]); + assert_eq!(result.code, Some(0), "{}", result.stderr); + let calls = scenario.agy_calls(); + assert_eq!(calls[0]["gemini_link"], "CLAUDE.md"); + assert_eq!(calls[0]["gemini_text"], "project rules\n"); + assert_eq!(calls[0]["git_status"], ""); + assert_eq!(scenario.origin_file("issue-7", "GEMINI.md"), None); +} + +#[test] +fn a_session_with_unfinished_work_is_resumed_by_conversation_with_the_same_flags() { + let scenario = Scenario::new(); + scenario.agent_does_for_in_session(7, 1, &format!(r#"{AGENT_OPENS_PR} +echo '{{"event":"step_update","step_update":{{"step_index":2,"state":"ACTIVE","step_type":"tool","tool_name":"run_command","tool_info":{{"parameters":{{"CommandLine":"cargo test"}}}}}}}}' +"#)); + let result = scenario.run(&[ + "harness", + "agy", + "model", + "gemini-3.8-flash-high", + &scenario.issue_url(7), + ]); + assert_eq!(result.code, Some(0), "{}", result.stderr); + let calls = scenario.agy_calls(); + assert_eq!(calls.len(), 2, "{}", result.stderr); + let args = calls[1]["argv"].as_array().unwrap(); + assert_eq!(args[6], "--conversation"); + assert_eq!(args[7], "fake-conversation-1"); + assert!( + calls[1]["prompt"] + .as_str() + .unwrap() + .starts_with("Your background work (cargo test) was killed") + ); + for call in calls { + assert_eq!(call["auto_update"], "true"); + assert_eq!(call["stdin_null"], true); + assert_eq!(call["argv"][1], "--dangerously-skip-permissions"); + assert_eq!(call["argv"][3], "stream-json"); + assert_eq!(call["argv"][5], "gemini-3.8-flash-high"); + } +} + +#[test] +fn existing_instruction_files_win_and_no_link_is_made_without_claude() { + for existing in [None, Some("AGENTS.md"), Some("GEMINI.md")] { + let scenario = Scenario::new(); + if let Some(existing) = existing { + std::fs::write(scenario.launch_dir().join("CLAUDE.md"), "claude rules\n").unwrap(); + std::fs::write(scenario.launch_dir().join(existing), "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", "agy", &scenario.issue_url(7)]); + assert_eq!(result.code, Some(0), "{}", result.stderr); + let calls = scenario.agy_calls(); + assert_eq!(calls[0]["gemini_link"], serde_json::Value::Null); + if existing == Some("GEMINI.md") { + assert_eq!(calls[0]["gemini_text"], "own rules\n"); + } + assert!( + scenario.agy_checks().is_empty(), + "no named settings need a catalog check" + ); + } +} + +#[test] +fn bad_models_efforts_and_catalog_failure_stop_before_any_work() { + for (model, effort, error) in [ + ("gemini-99", "high", "choose one of gemini-3.8-flash-high"), + ("Gemini-3.1-Pro", "Medium", "choose one of low, high"), + ( + "gemini-3.8-flash", + "", + "requires an Effort: choose one of low, medium, high", + ), + ( + "gemini-3.8-flash-high", + "Max", + "choose one of low, medium, high", + ), + ] { + let scenario = Scenario::new(); + let mut args = vec!["harness", "agy", "model", model]; + if !effort.is_empty() { + args.extend(["effort", effort]); + } + let url = scenario.issue_url(7); + args.push(&url); + let result = scenario.run(&args); + assert_eq!(result.code, Some(1), "{}", result.stderr); + assert!(result.stderr.contains(error), "{}", result.stderr); + assert!(scenario.agy_calls().is_empty()); + assert!(scenario.issue_labels(7).is_empty()); + assert_eq!(scenario.entries("work"), ["widgets"]); + assert!( + !scenario + .path("home/.thirdshift/logs/acme/widgets/commands") + .exists() + ); + } + let scenario = Scenario::new(); + let result = scenario.run_with_env( + &[ + "harness", + "agy", + "model", + "gemini-3.8-flash-high", + &scenario.issue_url(7), + ], + &[("FAKE_AGY_CATALOG_ERROR", "sign in first")], + ); + assert_eq!(result.code, Some(1)); + assert!( + result.stderr.contains( + "agy models failed, so Antigravity CLI's Models can't be read: sign in first" + ), + "{}", + result.stderr + ); + assert!(scenario.agy_calls().is_empty()); +} + +#[test] +fn an_agy_user_config_and_command_overrides_choose_its_own_settings() { + let scenario = Scenario::new(); + scenario.user_config_is("[harness]\ndefault = \"agy\"\n[harness.agy]\nmodel = \"Gemini 3.8 Flash (High)\"\neffort = \"HIGH\"\n"); + scenario.agent_does_for(7, AGENT_OPENS_PR); + let result = scenario.run(&[&scenario.issue_url(7), "effort", "Low"]); + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert!( + result + .stderr + .contains("sessions run on agy · gemini-3.8-flash-high · low"), + "{}", + result.stderr + ); +} + +#[test] +fn an_error_result_or_nonzero_exit_fails_with_agys_own_error() { + for code in [0, 3] { + let scenario = Scenario::new(); + scenario.agent_does_for(7, &format!(r#" +echo '{{"event":"result","result":{{"status":"ERROR","error":"backend unavailable","response":""}}}}' > "$FAKE_AGY_RESULT" +exit {code} +"#)); + let result = scenario.run(&["harness", "agy", &scenario.issue_url(7)]); + assert_eq!(result.code, Some(1), "{}", result.stderr); + let ending = if code == 0 { + "agy's turn failed" + } else { + "agy exited 3" + }; + assert!( + result + .stderr + .contains(&format!("{ending}: backend unavailable")), + "{}", + result.stderr + ); + assert_eq!(scenario.agy_calls().len(), 1); + } +} + +#[test] +fn an_architecture_review_uses_the_final_result_instead_of_text_deltas() { + let scenario = Scenario::new(); + scenario.repo_has_labels(&["needs-triage", "ready-for-agent"]); + scenario.user_config_is("[harness]\ndefault = \"agy\"\n"); + scenario.agent_does(r#" +gh issue create --title Plan --body Plan --label needs-triage > /dev/null +echo '{"event":"step_update","step_update":{"step_index":1,"step_type":"agent_response","state":"DONE","text_delta":"Architecture review plan: https://github.com/acme/widgets/issues/99"}}' +echo 'Architecture review plan: https://github.com/acme/widgets/issues/8' > "$FAKE_CLAUDE_FINAL_MESSAGE" +"#); + let result = scenario.run(&["architect", "--plan-only"]); + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert!( + result + .stderr + .contains("published the plan https://github.com/acme/widgets/issues/8"), + "{}", + result.stderr + ); + assert_eq!(scenario.agy_calls().len(), 1); + assert!( + scenario.agy_calls()[0]["prompt"] + .as_str() + .unwrap() + .starts_with("/thirdshift-improve-codebase-architecture") + ); +} + +#[test] +fn a_repair_uses_the_commands_agy_model_effort_and_environment() { + let scenario = Scenario::new(); + scenario.agent_does_for_in_session(7, 1, &format!(r#"{AGENT_OPENS_PR} +gh fake checks "$(git rev-parse HEAD)" '[{{"name":"test","conclusion":"failure","url":"https://ci.example/test"}}]' +"#)); + scenario.agent_does_for_in_session(7, 2, r#" +echo fix > fix.txt +git add fix.txt +git commit -q -m Fix +gh fake checks "$(git rev-parse HEAD)" '[{"name":"test","conclusion":"success","url":"https://ci.example/test"}]' +"#); + let result = scenario.run(&[ + "harness", + "agy", + "model", + "gemini-3.8-flash", + "effort", + "high", + &scenario.issue_url(7), + ]); + assert_eq!(result.code, Some(0), "{}", result.stderr); + let calls = scenario.agy_calls(); + assert_eq!(calls.len(), 2); + assert!(calls[1]["prompt"].as_str().unwrap().contains("CI")); + for call in calls { + assert_eq!(call["argv"][5], "gemini-3.8-flash"); + assert_eq!(call["argv"][7], "high"); + assert_eq!(call["auto_update"], "true"); + assert_eq!(call["stdin_null"], true); + } + assert!(scenario.claude_calls().is_empty()); + assert!(scenario.codex_calls().is_empty()); +} + +#[test] +fn an_interrupt_stops_an_agy_session_and_saves_its_work() { + let scenario = Scenario::new(); + scenario.agent_does_for( + 7, + &format!( + "echo 'half done' > wip.txt\ntouch '{}'\nsleep 900\n", + scenario.path("agy-started").display() + ), + ); + let result = scenario.run_and_signal( + &["harness", "agy", &scenario.issue_url(7)], + "agy-started", + "TERM", + ); + assert_eq!(result.code, Some(1), "{}", result.stderr); + assert!(result.stderr.contains("interrupted"), "{}", result.stderr); + assert_eq!( + scenario.origin_file("issue-7", "wip.txt"), + Some("half done\n".to_string()) + ); +} diff --git a/tests/fakes.rs b/tests/fakes.rs index 0b197d18a..b69827870 100644 --- a/tests/fakes.rs +++ b/tests/fakes.rs @@ -13,6 +13,8 @@ #![cfg_attr(test, allow(dead_code))] +#[path = "fakes/agy.rs"] +mod agy; #[path = "fakes/claude.rs"] mod claude; #[path = "fakes/codex.rs"] @@ -110,6 +112,7 @@ fn main() { "gh" => gh::main(args.collect()), "claude" => claude::main(args.collect()), "codex" => codex::main(args.collect()), + "agy" => agy::main(args.collect()), _ => die(&format!("fake: no fake is called {name}"), 2), } exit(0); diff --git a/tests/fakes/agy.rs b/tests/fakes/agy.rs new file mode 100644 index 000000000..84d38d095 --- /dev/null +++ b/tests/fakes/agy.rs @@ -0,0 +1,153 @@ +//! Fake Antigravity CLI: a free tab-separated catalog and recorded agy +//! stream envelopes, running the same per-session scripts as fake Claude. +use std::fs; +use std::io::Write; +use std::process::Command; + +use crate::claude::{exit_code, git_here, script_for, skills_snapshot}; +use crate::codex::stdin_is_null; +use crate::json::{Array, Bool, Json, Null, number, object, string}; + +const CATALOG: &str = "gemini-3.8-flash-high\tGemini 3.8 Flash (High)\ngemini-3.8-flash-medium\tGemini 3.8 Flash (Medium)\ngemini-3.8-flash-low\tGemini 3.8 Flash (Low)\ngemini-3.1-pro-high\tGemini 3.1 Pro (High)\ngemini-3.1-pro-low\tGemini 3.1 Pro (Low)\n"; + +fn emit(event: Json) { + println!("{event}"); + std::io::stdout().flush().unwrap(); +} + +pub fn main(argv: Vec) { + let catalog = argv == ["models"]; + let record_path = crate::env_path(if catalog { + "FAKE_AGY_CHECK_RECORD" + } else { + "FAKE_AGY_RECORD" + }); + let lock = crate::lock_beside(&record_path); + let (branch, _) = git_here(&["branch", "--show-current"]); + let (status, _) = git_here(&["status", "--porcelain"]); + let records = crate::append_record( + &record_path, + object([ + ("argv", Array(argv.iter().map(string).collect())), + ( + "prompt", + if catalog { + Null + } else { + argv.last().map(string).unwrap_or(Null) + }, + ), + ("branch", string(branch)), + ( + "cwd", + string(std::env::current_dir().unwrap().display().to_string()), + ), + ("stdin_null", Bool(stdin_is_null())), + ("git_status", string(status)), + ( + "auto_update", + string(std::env::var("AGY_CLI_DISABLE_AUTO_UPDATE").unwrap_or_default()), + ), + ( + "skill_files", + if catalog { + object([]) + } else { + skills_snapshot(".agents/skills") + }, + ), + ( + "gemini_link", + fs::read_link("GEMINI.md") + .ok() + .map(|p| string(p.display().to_string())) + .unwrap_or(Null), + ), + ( + "gemini_text", + fs::read_to_string("GEMINI.md") + .ok() + .map(string) + .unwrap_or(Null), + ), + ]), + ); + drop(lock); + if catalog { + if let Ok(error) = std::env::var("FAKE_AGY_CATALOG_ERROR") { + crate::die(&error, 1); + } + print!("{CATALOG}"); + crate::exit(0); + } + assert!(argv.iter().any(|arg| arg == "-p")); + let records = records.items(); + let session = records.len(); + let conversation = argv + .windows(2) + .find(|args| args[0] == "--conversation") + .map(|args| args[1].clone()) + .unwrap_or_else(|| format!("fake-conversation-{session}")); + emit(object([ + ("event", string("init")), + ("conversation_id", string(&conversation)), + ( + "init", + object([("permission_mode", string("always-proceed"))]), + ), + ])); + let final_message = format!("{}.final-message.{session}", record_path.display()); + let error = format!("{}.error.{session}", record_path.display()); + let result = format!("{}.result.{session}", record_path.display()); + let code = exit_code( + Command::new("bash") + .arg("-e") + .arg(script_for(records)) + .env("FAKE_CLAUDE_FINAL_MESSAGE", &final_message) + .env("FAKE_AGY_ERROR", &error) + .env("FAKE_AGY_RESULT", &result) + .status() + .unwrap(), + ); + if let Ok(result) = fs::read_to_string(result) { + emit(crate::json::parse(&result).unwrap()); + crate::exit(code); + } + let response = fs::read_to_string(final_message).unwrap_or_default(); + let error = + fs::read_to_string(error).unwrap_or_else(|_| format!("the agent's script exited {code}")); + emit(object([ + ("event", string("result")), + ( + "result", + object([ + ("conversation_id", string(conversation)), + ( + "status", + string(if code == 0 { "SUCCESS" } else { "ERROR" }), + ), + ("response", string(response)), + ( + "error", + if code == 0 { + Null + } else { + string(error.trim_end()) + }, + ), + ("num_turns", number(1)), + ( + "usage", + object([ + ("input_tokens", number(1200)), + ("cache_read_tokens", number(200)), + ("output_tokens", number(300)), + ("thinking_tokens", number(150)), + ("total_tokens", number(1500)), + ]), + ), + ]), + ), + ])); + crate::exit(code); +} diff --git a/tests/fakes/codex.rs b/tests/fakes/codex.rs index 4afee8063..2396018a3 100644 --- a/tests/fakes/codex.rs +++ b/tests/fakes/codex.rs @@ -49,7 +49,7 @@ const CATALOG: &str = r#"{"models": [ ]}"#; /// Whether stdin is `/dev/null`, by `fstat` on it, as macOS has no `/proc`. -fn stdin_is_null() -> bool { +pub fn stdin_is_null() -> bool { let stdin = std::io::stdin() .as_fd() .try_clone_to_owned() diff --git a/tests/setup.rs b/tests/setup.rs index f830ad39c..26719a880 100644 --- a/tests/setup.rs +++ b/tests/setup.rs @@ -52,7 +52,8 @@ fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() { "[pickup]", "[harness]", "[harness.claude]", - "[harness.codex]" + "[harness.codex]", + "[harness.agy]" ], "{text}" ); @@ -70,7 +71,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"] { + for harness in ["claude", "codex", "agy"] { 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 b851c6b75..7771d63aa 100644 --- a/tests/support/fakes.rs +++ b/tests/support/fakes.rs @@ -11,17 +11,18 @@ 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; 5] = [ +const SOURCES: [&str; 6] = [ 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/json.rs"), ]; /// Put the fake `gh`, `claude` and `codex` in `bin`. pub fn install(bin: &Path) { - for name in ["gh", "claude", "codex"] { + for name in ["gh", "claude", "codex", "agy"] { std::os::unix::fs::symlink(executable(), bin.join(name)).unwrap(); } } diff --git a/tests/support/mod.rs b/tests/support/mod.rs index f593337b0..34ec5b166 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -603,6 +603,8 @@ test -f {root}/{COPY_REPLACED} .env("FAKE_CLAUDE_SCRIPT", self.path("claude-script.sh")) .env("FAKE_CLAUDE_RECORD", self.path("claude-calls.json")) .env("FAKE_CODEX_RECORD", self.path("codex-calls.json")) + .env("FAKE_AGY_RECORD", self.path("agy-calls.json")) + .env("FAKE_AGY_CHECK_RECORD", self.path("agy-checks.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, @@ -815,6 +817,21 @@ test -f {root}/{COPY_REPLACED} } } + pub fn agy_calls(&self) -> Vec { + self.agy_records("agy-calls.json") + } + + pub fn agy_checks(&self) -> Vec { + self.agy_records("agy-checks.json") + } + + fn agy_records(&self, file: &str) -> Vec { + fs::read_to_string(self.path(file)) + .ok() + .map(|text| serde_json::from_str(&text).unwrap()) + .unwrap_or_default() + } + /// Assert every `claude` call found every Factory skill, by its /// `thirdshift-` name, in its worktree's `.claude/skills/`. pub fn assert_every_session_found_the_factory_skills(&self) { From 5ac1f719f46a2f59efd6cb43238f57b9a5b7091a Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 6 Oct 2026 12:43:45 -0400 Subject: [PATCH 2/3] Avoid inferring killed agy work from active tool steps (#436) --- src/harness/agy.rs | 40 +++++++++++++++++++++++++++++++++++++++ src/harness/agy/stream.rs | 26 ++++++------------------- tests/agy_sessions.rs | 34 --------------------------------- 3 files changed, 46 insertions(+), 54 deletions(-) diff --git a/src/harness/agy.rs b/src/harness/agy.rs index 7a83e3ce0..9199b1fd8 100644 --- a/src/harness/agy.rs +++ b/src/harness/agy.rs @@ -113,3 +113,43 @@ impl Adapter for Agy { Box::new(stream::AgyProgress::default()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::harness::ChosenBy; + + /// Resume uses the same public invocation interface as a new session. + #[test] + fn a_resume_reuses_every_flag_and_environment_and_the_conversation_id() { + let chosen = Choice { + harness: Harness::Agy, + model: Some("gemini-3.8-flash".to_string()), + effort: Some("medium".to_string()), + chosen_by: ChosenBy::Command, + }; + let adapter = chosen.harness.adapter(); + let resume = adapter.session(&chosen, Some("recorded-conversation"), "Continue."); + assert_eq!( + resume.args, + [ + "-p", + "--dangerously-skip-permissions", + "--output-format", + "stream-json", + "--model", + "gemini-3.8-flash", + "--effort", + "medium", + "--conversation", + "recorded-conversation", + "Continue." + ] + ); + assert_eq!(resume.stdin, None); + assert_eq!( + adapter.environment(), + [("AGY_CLI_DISABLE_AUTO_UPDATE", "true")] + ); + } +} diff --git a/src/harness/agy/stream.rs b/src/harness/agy/stream.rs index 64cd70132..df6ee94d0 100644 --- a/src/harness/agy/stream.rs +++ b/src/harness/agy/stream.rs @@ -2,14 +2,13 @@ //! usage come only from the final result, never concatenated text deltas. use crate::progress::{Stream, bash, shorten}; use serde_json::Value; -use std::collections::{BTreeMap, HashSet}; +use std::collections::HashSet; #[derive(Default)] pub struct AgyProgress { session_id: Option, tools: HashSet, result: Option, - running: BTreeMap, } impl Stream for AgyProgress { @@ -33,9 +32,6 @@ impl Stream for AgyProgress { let Some(index) = step["step_index"].as_u64() else { return Vec::new(); }; - if step["state"] == "DONE" { - self.running.remove(&index); - } if !self.tools.insert(index) { return Vec::new(); } @@ -59,15 +55,6 @@ impl Stream for AgyProgress { ) } }; - if step["state"] == "ACTIVE" && matches!(name, "run_command" | "invoke_subagent") { - self.running.insert( - index, - description - .strip_prefix("$ ") - .unwrap_or(&description) - .to_string(), - ); - } vec![description] } Some("result") => { @@ -100,10 +87,9 @@ impl Stream for AgyProgress { self.session_id.as_deref() } fn killed_background_work(&self) -> Vec<&str> { - if self.failed() { - return Vec::new(); - } - self.running.values().map(String::as_str).collect() + // ACTIVE describes a tool step, not background work killed at exit. + // The recorded protocol has no explicit killed-work signal. + Vec::new() } fn failed(&self) -> bool { self.result @@ -162,11 +148,11 @@ mod tests { } #[test] - fn unfinished_commands_are_resume_work_only_on_success() { + fn an_active_tool_step_does_not_prove_background_work_was_killed() { let mut stream = AgyProgress::default(); stream.condense(TOOL); stream.condense(RESULT); - assert_eq!(stream.killed_background_work(), ["cargo test"]); + assert!(stream.killed_background_work().is_empty()); stream.condense(r#"{"event":"result","result":{"status":"ERROR","error":"interrupted"}}"#); assert!(stream.killed_background_work().is_empty()); } diff --git a/tests/agy_sessions.rs b/tests/agy_sessions.rs index 3a7386d40..49deb0aad 100644 --- a/tests/agy_sessions.rs +++ b/tests/agy_sessions.rs @@ -118,40 +118,6 @@ fn claude_only_instructions_are_linked_as_gemini_and_kept_out_of_git() { assert_eq!(scenario.origin_file("issue-7", "GEMINI.md"), None); } -#[test] -fn a_session_with_unfinished_work_is_resumed_by_conversation_with_the_same_flags() { - let scenario = Scenario::new(); - scenario.agent_does_for_in_session(7, 1, &format!(r#"{AGENT_OPENS_PR} -echo '{{"event":"step_update","step_update":{{"step_index":2,"state":"ACTIVE","step_type":"tool","tool_name":"run_command","tool_info":{{"parameters":{{"CommandLine":"cargo test"}}}}}}}}' -"#)); - let result = scenario.run(&[ - "harness", - "agy", - "model", - "gemini-3.8-flash-high", - &scenario.issue_url(7), - ]); - assert_eq!(result.code, Some(0), "{}", result.stderr); - let calls = scenario.agy_calls(); - assert_eq!(calls.len(), 2, "{}", result.stderr); - let args = calls[1]["argv"].as_array().unwrap(); - assert_eq!(args[6], "--conversation"); - assert_eq!(args[7], "fake-conversation-1"); - assert!( - calls[1]["prompt"] - .as_str() - .unwrap() - .starts_with("Your background work (cargo test) was killed") - ); - for call in calls { - assert_eq!(call["auto_update"], "true"); - assert_eq!(call["stdin_null"], true); - assert_eq!(call["argv"][1], "--dangerously-skip-permissions"); - assert_eq!(call["argv"][3], "stream-json"); - assert_eq!(call["argv"][5], "gemini-3.8-flash-high"); - } -} - #[test] fn existing_instruction_files_win_and_no_link_is_made_without_claude() { for existing in [None, Some("AGENTS.md"), Some("GEMINI.md")] { From 6fc043a8967ef0e80b8c763661b7647d857cfe31 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 6 Oct 2026 12:46:32 -0400 Subject: [PATCH 3/3] Include agy settings in the config key regression check (#436) --- src/config.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 8c80d6a3f..533a6920d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -531,7 +531,9 @@ mod tests { "harness.claude.model", "harness.claude.effort", "harness.codex.model", - "harness.codex.effort" + "harness.codex.effort", + "harness.agy.model", + "harness.agy.effort" ] ); let commented_out: Vec<&str> = DEFAULTS