From 8698e5f26a26655092f83ce0abdce99db2b6abf0 Mon Sep 17 00:00:00 2001 From: Blake Wood Date: Fri, 25 Sep 2026 16:31:03 -0700 Subject: [PATCH 1/5] feat(providers): add Gemini CLI runtime --- crates/modula_services/provider_catalog.toml | 15 + .../modula_services/src/providers/gemini.rs | 319 ++++++++++++++++++ crates/modula_services/src/providers/mod.rs | 13 +- crates/modula_services/src/tools.rs | 2 +- crates/modula_services/src/usage.rs | 66 +++- 5 files changed, 396 insertions(+), 19 deletions(-) create mode 100644 crates/modula_services/src/providers/gemini.rs diff --git a/crates/modula_services/provider_catalog.toml b/crates/modula_services/provider_catalog.toml index 03e0b8c..ad8bc13 100644 --- a/crates/modula_services/provider_catalog.toml +++ b/crates/modula_services/provider_catalog.toml @@ -40,3 +40,18 @@ models = [ { id = "openai/gpt-4o", label = "GPT-4o (OpenAI)" }, { id = "google/gemini-2.5-pro", label = "Gemini 2.5 Pro (Google)" }, ] + +[[providers]] +id = "gemini" +models = [ + { id = "auto", label = "Auto (alias)" }, + { id = "pro", label = "Pro (alias)" }, + { id = "flash", label = "Flash (alias)" }, + { id = "flash-lite", label = "Flash Lite (alias)" }, + { id = "gemini-3.1-pro-preview", label = "Gemini 3.1 Pro (preview)" }, + { id = "gemini-3-pro-preview", label = "Gemini 3 Pro (preview)" }, + { id = "gemini-3.8-flash", label = "Gemini 3.8 Flash" }, + { id = "gemini-3.5-flash", label = "Gemini 3.5 Flash" }, + { id = "gemini-3.5-flash-lite", label = "Gemini 3.5 Flash Lite" }, + { id = "gemini-2.5-pro", label = "Gemini 2.5 Pro" }, +] diff --git a/crates/modula_services/src/providers/gemini.rs b/crates/modula_services/src/providers/gemini.rs new file mode 100644 index 0000000..ac03484 --- /dev/null +++ b/crates/modula_services/src/providers/gemini.rs @@ -0,0 +1,319 @@ +//! Gemini provider — `gemini` CLI. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{json, Value as JsonValue}; + +use super::{program, ChatEvent, ProviderRuntime}; + +/// `config_dir` is the `.gemini` dir itself; the CLI finds it as `$GEMINI_CLI_HOME/.gemini`. +pub struct GeminiRuntime { + pub config_dir: PathBuf, + pub model: Option, +} + +impl GeminiRuntime { + fn command(&self, prompt: &str, session_flag: Option<(&str, &str)>) -> Command { + let mut cmd = Command::new(program("gemini")); + // `--skip-trust`: an untrusted cwd exits 55 and silently drops `--yolo`. + cmd.arg("-o") + .arg("stream-json") + .arg("--skip-trust") + .arg("--yolo"); + if let Some(m) = &self.model { + cmd.arg("-m").arg(m); + } + if let Some((flag, id)) = session_flag { + cmd.arg(flag).arg(id); + } + // One token, so a prompt starting with `-` isn't parsed as a flag. + cmd.arg(format!("--prompt={prompt}")); + cmd + } +} + +impl ProviderRuntime for GeminiRuntime { + fn build_command(&self, prompt: &str, session_id: Option<&str>) -> Command { + self.command(prompt, session_id.map(|id| ("--resume", id))) + } + + fn build_command_chat_first(&self, prompt: &str, preset_session_id: &str) -> Option { + Some(self.command(prompt, Some(("--session-id", preset_session_id)))) + } + + fn env_vars(&self) -> Vec<(&'static str, OsString)> { + let mut vars: Vec<(&'static str, OsString)> = Vec::new(); + if let Some(home) = self.config_dir.parent() { + vars.push(("GEMINI_CLI_HOME", home.as_os_str().to_owned())); + } + if let Some(m) = &self.model { + vars.push(("MODULA_PROVIDER_MODEL", OsString::from(m))); + } + vars + } + + fn mcp_summary(&self) -> JsonValue { + gemini_mcp_summary(&self.config_dir) + } + + fn parse_line(&self, v: &JsonValue) -> Vec { + match v["type"].as_str().unwrap_or("") { + "init" => match v["session_id"].as_str() { + Some(id) => vec![ChatEvent::Session { id: id.to_string() }], + None => vec![], + }, + "message" if v["role"].as_str() == Some("assistant") => match v["content"].as_str() { + Some(text) => vec![ChatEvent::Delta { + text: text.to_string(), + }], + None => vec![], + }, + "tool_use" => { + let params = &v["parameters"]; + let (name, input) = match v["tool_name"].as_str().unwrap_or("") { + "run_shell_command" => ("Bash", json!({ "command": params["command"] })), + "write_file" | "replace" => { + ("Edit", json!({ "file_path": params["file_path"] })) + } + other => (other, params.clone()), + }; + vec![ChatEvent::ToolUse { + name: name.to_string(), + input, + }] + } + "tool_result" => vec![ChatEvent::ToolResult], + "result" if v["status"].as_str() == Some("success") => vec![ChatEvent::Done], + "result" => vec![ChatEvent::Error { + message: v["error"]["message"] + .as_str() + .unwrap_or("gemini run failed") + .to_string(), + }], + _ => vec![], + } + } +} + +/// Best-effort JSONC read of `settings.json`; a missing or unparsable file +/// degrades to an empty object. +fn read_jsonc(path: &Path) -> JsonValue { + let Ok(text) = std::fs::read_to_string(path) else { + return JsonValue::Object(Default::default()); + }; + jsonc_parser::parse_to_serde_value(&text, &jsonc_parser::ParseOptions::default()) + .ok() + .flatten() + .unwrap_or(JsonValue::Object(Default::default())) +} + +fn gemini_mcp_summary(config_dir: &Path) -> JsonValue { + let config_file = config_dir.join("settings.json"); + let config_exists = config_file.is_file(); + let data = read_jsonc(&config_file); + + let mut projects: Vec = Vec::new(); + if let Some(mcp) = data.get("mcpServers").and_then(|v| v.as_object()) { + let mut names: Vec<&String> = mcp.keys().collect(); + names.sort(); + let mut servers: Vec = Vec::new(); + for name in names { + let Some(cfg) = mcp.get(name).and_then(|v| v.as_object()) else { + continue; + }; + // Gemini's precedence: `httpUrl` > `url` > `command`. + let (kind, url) = if let Some(u) = cfg.get("httpUrl") { + (Some("http"), Some(u)) + } else if let Some(u) = cfg.get("url") { + (Some("sse"), Some(u)) + } else if cfg.contains_key("command") { + (Some("stdio"), None) + } else { + (None, None) + }; + servers.push(json!({ + "name": name, + "type": kind, + "url": url, + "command": cfg.get("command"), + "needs_auth": false, + })); + } + if !servers.is_empty() { + let count = servers.len(); + projects.push(json!({ + "path": "gemini (global)", + "mcp_servers": servers, + "count": count, + })); + } + } + json!({ + "config_exists": config_exists, + "projects": projects, + "needs_auth": {}, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(cmd: &Command) -> Vec<&std::ffi::OsStr> { + cmd.get_args().collect() + } + + fn gemini_rt() -> GeminiRuntime { + GeminiRuntime { + config_dir: "/home/u/.gemini".into(), + model: None, + } + } + + #[test] + fn gemini_build_command_argv() { + let cmd = gemini_rt().build_command("- list", None); + assert_eq!( + args(&cmd), + &[ + "-o", + "stream-json", + "--skip-trust", + "--yolo", + "--prompt=- list" + ] + ); + } + + #[test] + fn gemini_build_command_resume_and_model() { + let rt = GeminiRuntime { + config_dir: "/home/u/.gemini".into(), + model: Some("flash".to_string()), + }; + let cmd = rt.build_command("hello", Some("sid-1")); + assert_eq!( + args(&cmd)[4..], + ["-m", "flash", "--resume", "sid-1", "--prompt=hello"] + ); + } + + #[test] + fn gemini_chat_first_presets_session_id() { + let cmd = gemini_rt() + .build_command_chat_first("hello", "uuid-1") + .unwrap(); + assert_eq!( + args(&cmd)[4..], + ["--session-id", "uuid-1", "--prompt=hello"] + ); + } + + #[test] + fn gemini_env_vars() { + let rt = GeminiRuntime { + config_dir: "/home/u/.gemini".into(), + model: Some("pro".to_string()), + }; + let vars = rt.env_vars(); + assert_eq!( + vars, + vec![ + ("GEMINI_CLI_HOME", OsString::from("/home/u")), + ("MODULA_PROVIDER_MODEL", OsString::from("pro")), + ] + ); + } + + #[test] + fn gemini_parse_events() { + let rt = gemini_rt(); + let parse = |line: &str| rt.parse_stream_line(line); + assert!(matches!( + &parse(r#"{"type":"init","session_id":"s1","model":"auto"}"#)[..], + [ChatEvent::Session { id }] if id == "s1" + )); + assert!(parse(r#"{"type":"message","role":"user","content":"hi"}"#).is_empty()); + assert!(matches!( + &parse(r#"{"type":"message","role":"assistant","content":"Red","delta":true}"#)[..], + [ChatEvent::Delta { text }] if text == "Red" + )); + assert!(matches!( + &parse(r#"{"type":"tool_use","tool_name":"run_shell_command","tool_id":"t","parameters":{"command":"echo hi","description":"x"}}"#)[..], + [ChatEvent::ToolUse { name, input }] if name == "Bash" && input == &json!({"command":"echo hi"}) + )); + assert!(matches!( + &parse(r#"{"type":"tool_use","tool_name":"replace","tool_id":"t","parameters":{"file_path":"a.txt","old_string":"hi","new_string":"bye"}}"#)[..], + [ChatEvent::ToolUse { name, input }] if name == "Edit" && input == &json!({"file_path":"a.txt"}) + )); + assert!(matches!( + &parse(r#"{"type":"tool_use","tool_name":"glob","tool_id":"t","parameters":{"pattern":"*.rs"}}"#)[..], + [ChatEvent::ToolUse { name, input }] if name == "glob" && input["pattern"] == "*.rs" + )); + assert!(matches!( + parse(r#"{"type":"tool_result","tool_id":"t","status":"success"}"#)[..], + [ChatEvent::ToolResult] + )); + assert!(parse(r#"{"type":"error","severity":"warning","message":"retrying"}"#).is_empty()); + assert!(matches!( + parse(r#"{"type":"result","status":"success","stats":{}}"#)[..], + [ChatEvent::Done] + )); + assert!(matches!( + &parse(r#"{"type":"result","status":"error","error":{"type":"x","message":"quota"}}"#)[..], + [ChatEvent::Error { message }] if message == "quota" + )); + assert!(matches!( + &parse(r#"{"type":"result","status":"error"}"#)[..], + [ChatEvent::Error { message }] if message == "gemini run failed" + )); + } + + #[test] + fn gemini_mcp_summary_missing_dir() { + let rt = GeminiRuntime { + config_dir: "/nonexistent/path/xyz".into(), + model: None, + }; + let s = rt.mcp_summary(); + assert_eq!(s["config_exists"], false); + assert_eq!(s["projects"].as_array().unwrap().len(), 0); + } + + #[test] + fn gemini_mcp_summary_parses_mcp_servers() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("settings.json"), + r#"{ + // comments are allowed + "mcpServers": { + "modula": { "httpUrl": "http://127.0.0.1:1/mcp" }, + "legacy": { "url": "http://127.0.0.1:2/sse" }, + "local": { "command": "npx", "args": ["x"] } + } + }"#, + ) + .unwrap(); + let rt = GeminiRuntime { + config_dir: tmp.path().into(), + model: None, + }; + let s = rt.mcp_summary(); + assert_eq!(s["config_exists"], true); + let project = &s["projects"][0]; + assert_eq!(project["path"], "gemini (global)"); + assert_eq!(project["count"], 3); + let servers = project["mcp_servers"].as_array().unwrap(); + assert_eq!(servers[0]["name"], "legacy"); + assert_eq!(servers[0]["type"], "sse"); + assert_eq!(servers[1]["name"], "local"); + assert_eq!(servers[1]["type"], "stdio"); + assert_eq!(servers[1]["command"], "npx"); + assert_eq!(servers[2]["name"], "modula"); + assert_eq!(servers[2]["type"], "http"); + assert_eq!(servers[2]["url"], "http://127.0.0.1:1/mcp"); + } +} diff --git a/crates/modula_services/src/providers/mod.rs b/crates/modula_services/src/providers/mod.rs index 5c2a13b..ba612cd 100644 --- a/crates/modula_services/src/providers/mod.rs +++ b/crates/modula_services/src/providers/mod.rs @@ -16,11 +16,13 @@ use serde_json::Value as JsonValue; mod claude; mod codex; +mod gemini; mod opencode; mod service; use claude::ClaudeRuntime; use codex::CodexRuntime; +use gemini::GeminiRuntime; use opencode::OpenCodeRuntime; pub use service::{CatalogEntry, CreateParams, CreatedProvider, ProviderService, UpdateParams}; @@ -196,6 +198,7 @@ pub(super) fn build( "claude" => Some(Arc::new(ClaudeRuntime { config_dir, model })), "opencode" => Some(Arc::new(OpenCodeRuntime { config_dir, model })), "codex" => Some(Arc::new(CodexRuntime { config_dir, model })), + "gemini" => Some(Arc::new(GeminiRuntime { config_dir, model })), _ => None, } } @@ -257,8 +260,10 @@ mod tests { #[test] fn from_provider_hydrates_known_type() { let tmp = tempfile::tempdir().unwrap(); - let p = provider("claude", tmp.path().to_str().unwrap()); - assert!(ProviderService::runtime_from_provider(&p, Some("opus".to_string())).is_ok()); + for ptype in ["claude", "gemini"] { + let p = provider(ptype, tmp.path().to_str().unwrap()); + assert!(ProviderService::runtime_from_provider(&p, Some("opus".to_string())).is_ok()); + } } #[test] @@ -275,8 +280,8 @@ mod tests { id: "opus".to_string(), label: "Opus".to_string(), }]; - // Claude and Codex use the trait's default (no discovery mechanism). - for ptype in ["claude", "codex"] { + // These use the trait's default (no discovery mechanism). + for ptype in ["claude", "codex", "gemini"] { let rt = ProviderService::runtime_for_type(ptype, "/tmp".into()).unwrap(); assert_eq!(rt.models(static_models.clone()).await, static_models); } diff --git a/crates/modula_services/src/tools.rs b/crates/modula_services/src/tools.rs index a839474..8ed5a91 100644 --- a/crates/modula_services/src/tools.rs +++ b/crates/modula_services/src/tools.rs @@ -7,7 +7,7 @@ use modula_platform as platform; -pub const TOOLS: [&str; 4] = ["gh", "claude", "codex", "opencode"]; +pub const TOOLS: [&str; 5] = ["gh", "claude", "codex", "opencode", "gemini"]; pub fn detect() -> Vec<(&'static str, bool)> { TOOLS diff --git a/crates/modula_services/src/usage.rs b/crates/modula_services/src/usage.rs index 61bb791..e128a8e 100644 --- a/crates/modula_services/src/usage.rs +++ b/crates/modula_services/src/usage.rs @@ -1,6 +1,6 @@ //! Per-agent-run cost + token parsing. Reads the `type: result` event that -//! Claude emits at the end of every stream-json run. Pure log-file helpers with -//! no repo/business logic — `RunService::usage` drives them over its runs. +//! Claude and Gemini emit at the end of every stream-json run. Pure log-file +//! helpers with no repo/business logic — `RunService::usage` drives them over its runs. use std::fs; use std::io::{BufRead, BufReader}; @@ -48,29 +48,67 @@ pub fn log_summary(path: &Path) -> Option { if event.get("type").and_then(|v| v.as_str()) != Some("result") { continue; } - let usage = event.get("usage"); - let token = |key: &str| { - usage - .and_then(|u| u.get(key)) + let int = |v: Option<&JsonValue>, key: &str| { + v.and_then(|u| u.get(key)) .and_then(|v| v.as_i64()) .unwrap_or(0) }; + // Gemini reports `stats` instead of `usage`, and no cost. + if event.get("usage").is_none() { + if let Some(stats) = event.get("stats") { + return Some(LogSummary { + cost_usd: 0.0, + duration_ms: int(Some(stats), "duration_ms"), + tokens: UsageTokens { + input: int(Some(stats), "input_tokens"), + output: int(Some(stats), "output_tokens"), + cache_creation: 0, + cache_read: int(Some(stats), "cached"), + }, + }); + } + } + let usage = event.get("usage"); return Some(LogSummary { cost_usd: event .get("total_cost_usd") .and_then(|v| v.as_f64()) .unwrap_or(0.0), - duration_ms: event - .get("duration_ms") - .and_then(|v| v.as_i64()) - .unwrap_or(0), + duration_ms: int(Some(&event), "duration_ms"), tokens: UsageTokens { - input: token("input_tokens"), - output: token("output_tokens"), - cache_creation: token("cache_creation_input_tokens"), - cache_read: token("cache_read_input_tokens"), + input: int(usage, "input_tokens"), + output: int(usage, "output_tokens"), + cache_creation: int(usage, "cache_creation_input_tokens"), + cache_read: int(usage, "cache_read_input_tokens"), }, }); } None } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_summary_reads_gemini_stats() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("run.log"); + fs::write( + &path, + concat!( + r#"{"type":"init","session_id":"s1","model":"auto"}"#, + "\n", + r#"{"type":"result","status":"success","stats":{"total_tokens":36787,"input_tokens":34727,"output_tokens":144,"cached":512,"input":34727,"duration_ms":12992,"tool_calls":2,"models":{}}}"#, + "\n", + ), + ) + .unwrap(); + let s = log_summary(&path).unwrap(); + assert_eq!(s.cost_usd, 0.0); + assert_eq!(s.duration_ms, 12992); + assert_eq!(s.tokens.input, 34727); + assert_eq!(s.tokens.output, 144); + assert_eq!(s.tokens.cache_read, 512); + } +} From 5905f2a304792d1101ff12bfab572fedc077d6df Mon Sep 17 00:00:00 2001 From: Blake Wood Date: Fri, 25 Sep 2026 16:38:36 -0700 Subject: [PATCH 2/5] feat(mcp): add Gemini MCP config strategy and e2e coverage --- .../modula_services/src/mcp_config/claude.rs | 2 +- .../modula_services/src/mcp_config/gemini.rs | 207 ++++++++++++++++++ crates/modula_services/src/mcp_config/mod.rs | 14 +- .../src/mcp_config/opencode.rs | 2 +- crates/modula_test_support/src/lib.rs | 4 +- tests/e2e/tests/gemini_provider.rs | 118 ++++++++++ tests/e2e/tests/provider_mcp.rs | 48 ++++ 7 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 crates/modula_services/src/mcp_config/gemini.rs create mode 100644 tests/e2e/tests/gemini_provider.rs diff --git a/crates/modula_services/src/mcp_config/claude.rs b/crates/modula_services/src/mcp_config/claude.rs index a6911ee..5ae6841 100644 --- a/crates/modula_services/src/mcp_config/claude.rs +++ b/crates/modula_services/src/mcp_config/claude.rs @@ -65,7 +65,7 @@ impl McpConfigStrategy for ClaudeStrategy { .or_insert_with(|| Value::Object(Map::new())) .as_object_mut() .ok_or_else(|| ApiError::BadRequest(format!("{FILE}: mcpServers is not an object")))?; - reconcile_json(servers, desired, entry); + reconcile_json(servers, desired, "url", entry); write_atomic(&path, &serde_json::to_string_pretty(&root)?) } } diff --git a/crates/modula_services/src/mcp_config/gemini.rs b/crates/modula_services/src/mcp_config/gemini.rs new file mode 100644 index 0000000..ae51883 --- /dev/null +++ b/crates/modula_services/src/mcp_config/gemini.rs @@ -0,0 +1,207 @@ +use std::path::Path; + +use serde_json::{json, Map, Value}; + +use super::{auth_token, reconcile_json, write_atomic, McpConfigStrategy, McpServer}; +use modula_core::error::{ApiError, ApiResult}; + +pub struct GeminiStrategy; + +const FILE: &str = "settings.json"; + +/// Gemini picks the transport by key: `httpUrl` is streamable HTTP, `url` is +/// SSE. We only manage `httpUrl` entries so a hand-written SSE server survives. +const URL_KEY: &str = "httpUrl"; + +/// Parse JSONC (Gemini tolerates comments in settings.json). +fn parse(text: &str) -> ApiResult { + let parsed: Option = + jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default()) + .map_err(|e| ApiError::BadRequest(format!("{FILE}: {e}")))?; + Ok(parsed.unwrap_or_else(|| Value::Object(Map::new()))) +} + +fn entry(server: &McpServer) -> Value { + let mut e = Map::new(); + e.insert(URL_KEY.into(), json!(server.url)); + if let Some(token) = auth_token(server) { + e.insert("headers".into(), json!({ "Authorization": token })); + } + Value::Object(e) +} + +impl McpConfigStrategy for GeminiStrategy { + fn read(&self, config_dir: &Path) -> ApiResult> { + let path = config_dir.join(FILE); + if !path.is_file() { + return Ok(vec![]); + } + let root = parse(&std::fs::read_to_string(&path)?)?; + let Some(servers) = root.get("mcpServers").and_then(Value::as_object) else { + return Ok(vec![]); + }; + Ok(servers + .iter() + .filter_map(|(key, cfg)| { + let url = cfg.get(URL_KEY).and_then(Value::as_str)?; + let auth_token = cfg + .get("headers") + .and_then(|h| h.get("Authorization")) + .and_then(Value::as_str) + .map(str::to_string); + Some(McpServer { + key: key.clone(), + url: url.to_string(), + auth_token, + }) + }) + .collect()) + } + + fn apply(&self, config_dir: &Path, desired: &[McpServer]) -> ApiResult<()> { + let path = config_dir.join(FILE); + let mut root = match path.is_file() { + true => parse(&std::fs::read_to_string(&path)?)?, + false => Value::Object(Map::new()), + }; + let obj = root + .as_object_mut() + .ok_or_else(|| ApiError::BadRequest(format!("{FILE}: root is not an object")))?; + let servers = obj + .entry("mcpServers") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or_else(|| ApiError::BadRequest(format!("{FILE}: mcpServers is not an object")))?; + reconcile_json(servers, desired, URL_KEY, entry); + write_atomic(&path, &serde_json::to_string_pretty(&root)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn srv(key: &str, url: &str, token: Option<&str>) -> McpServer { + McpServer { + key: key.into(), + url: url.into(), + auth_token: token.map(str::to_string), + } + } + + fn read_file(dir: &Path) -> Value { + serde_json::from_str(&std::fs::read_to_string(dir.join(FILE)).unwrap()).unwrap() + } + + #[test] + fn create_fresh_then_read_round_trips() { + let tmp = tempfile::tempdir().unwrap(); + let want = vec![srv( + "atlassian", + "https://mcp.atlassian.com/v1/mcp", + Some("tok"), + )]; + GeminiStrategy.apply(tmp.path(), &want).unwrap(); + + let v = read_file(tmp.path()); + assert_eq!( + v["mcpServers"]["atlassian"]["httpUrl"], + "https://mcp.atlassian.com/v1/mcp" + ); + assert!(v["mcpServers"]["atlassian"].get("url").is_none()); + assert_eq!( + v["mcpServers"]["atlassian"]["headers"]["Authorization"], + "Bearer tok" + ); + assert_eq!( + GeminiStrategy.read(tmp.path()).unwrap(), + vec![srv( + "atlassian", + "https://mcp.atlassian.com/v1/mcp", + Some("Bearer tok") + )] + ); + } + + #[test] + fn no_token_omits_headers() { + let tmp = tempfile::tempdir().unwrap(); + GeminiStrategy + .apply( + tmp.path(), + &[srv("linear", "https://mcp.linear.app/mcp", None)], + ) + .unwrap(); + let v = read_file(tmp.path()); + assert!(v["mcpServers"]["linear"].get("headers").is_none()); + } + + #[test] + fn preserves_unrelated_keys_stdio_and_sse_servers() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join(FILE), + r#"{ + // user settings + "security": { "auth": { "selectedType": "oauth-personal" } }, + "mcpServers": { + "local": { "command": "npx", "args": ["x"] }, + "sse": { "url": "https://sse.example/sse" }, + "old": { "httpUrl": "https://old.example/mcp" } + } +}"#, + ) + .unwrap(); + + GeminiStrategy + .apply( + tmp.path(), + &[srv("github", "https://api.githubcopilot.com/mcp/", None)], + ) + .unwrap(); + + let v = read_file(tmp.path()); + assert_eq!(v["security"]["auth"]["selectedType"], "oauth-personal"); + assert_eq!(v["mcpServers"]["local"]["command"], "npx"); + assert_eq!(v["mcpServers"]["sse"]["url"], "https://sse.example/sse"); + assert!(v["mcpServers"].get("old").is_none()); + assert_eq!( + GeminiStrategy.read(tmp.path()).unwrap(), + vec![srv("github", "https://api.githubcopilot.com/mcp/", None)] + ); + } + + #[test] + fn delete_clears_all_managed() { + let tmp = tempfile::tempdir().unwrap(); + GeminiStrategy + .apply( + tmp.path(), + &[srv("a", "https://a", None), srv("b", "https://b", None)], + ) + .unwrap(); + GeminiStrategy.apply(tmp.path(), &[]).unwrap(); + assert!(GeminiStrategy.read(tmp.path()).unwrap().is_empty()); + } + + #[test] + fn malformed_file_errors_without_clobbering() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join(FILE), "{ not json").unwrap(); + let err = GeminiStrategy.apply(tmp.path(), &[]).unwrap_err(); + assert!(matches!(err, ApiError::BadRequest(_))); + assert_eq!( + std::fs::read_to_string(tmp.path().join(FILE)).unwrap(), + "{ not json" + ); + } + + #[test] + fn missing_dir_reads_empty() { + let tmp = tempfile::tempdir().unwrap(); + assert!(GeminiStrategy + .read(&tmp.path().join("nope")) + .unwrap() + .is_empty()); + } +} diff --git a/crates/modula_services/src/mcp_config/mod.rs b/crates/modula_services/src/mcp_config/mod.rs index eccc27f..512f684 100644 --- a/crates/modula_services/src/mcp_config/mod.rs +++ b/crates/modula_services/src/mcp_config/mod.rs @@ -12,6 +12,7 @@ use modula_core::error::ApiResult; mod claude; mod codex; +mod gemini; mod opencode; /// A managed HTTP MCP server entry. `auth_token`, when present and non-blank, @@ -40,6 +41,7 @@ pub fn for_type(provider_type: &str) -> Option> { match provider_type { "claude" => Some(Box::new(claude::ClaudeStrategy)), "codex" => Some(Box::new(codex::CodexStrategy)), + "gemini" => Some(Box::new(gemini::GeminiStrategy)), "opencode" => Some(Box::new(opencode::OpenCodeStrategy)), _ => None, } @@ -64,16 +66,17 @@ fn auth_token(server: &McpServer) -> Option { } } -/// Reconcile a serde_json `mcp`/`mcpServers` map (claude + opencode share this -/// shape; codex uses toml_edit). Managed entries are those carrying a `url`; -/// the user's command/stdio entries have none and are left alone. +/// Reconcile a serde_json `mcp`/`mcpServers` map (claude, opencode and gemini +/// share this shape; codex uses toml_edit). Managed entries are those carrying +/// `url_key`; the user's other entries lack it and are left alone. fn reconcile_json( servers: &mut Map, desired: &[McpServer], + url_key: &str, make_entry: impl Fn(&McpServer) -> Value, ) { let keep: HashSet<&str> = desired.iter().map(|s| s.key.as_str()).collect(); - servers.retain(|key, cfg| cfg.get("url").is_none() || keep.contains(key.as_str())); + servers.retain(|key, cfg| cfg.get(url_key).is_none() || keep.contains(key.as_str())); for server in desired { servers.insert(server.key.clone(), make_entry(server)); } @@ -109,6 +112,7 @@ mod tests { fn for_type_dispatch() { assert!(for_type("claude").is_some()); assert!(for_type("codex").is_some()); + assert!(for_type("gemini").is_some()); assert!(for_type("opencode").is_some()); assert!(for_type("unknown").is_none()); } @@ -136,5 +140,5 @@ mod tests { ); } - // Per-strategy round-trip tests live in claude.rs / codex.rs / opencode.rs. + // Per-strategy round-trip tests live in each strategy module. } diff --git a/crates/modula_services/src/mcp_config/opencode.rs b/crates/modula_services/src/mcp_config/opencode.rs index b839ed4..9f2f246 100644 --- a/crates/modula_services/src/mcp_config/opencode.rs +++ b/crates/modula_services/src/mcp_config/opencode.rs @@ -80,7 +80,7 @@ impl McpConfigStrategy for OpenCodeStrategy { .ok_or_else(|| { ApiError::BadRequest(format!("{}: mcp is not an object", path.display())) })?; - reconcile_json(servers, desired, entry); + reconcile_json(servers, desired, "url", entry); write_atomic(&path, &serde_json::to_string_pretty(&root)?) } } diff --git a/crates/modula_test_support/src/lib.rs b/crates/modula_test_support/src/lib.rs index 85058ae..f506693 100644 --- a/crates/modula_test_support/src/lib.rs +++ b/crates/modula_test_support/src/lib.rs @@ -1,6 +1,6 @@ //! E2E test harness — boots the engine binary against a tempdir-rooted //! workspace root over a unique per-test local IPC socket, prepends provider -//! shims (`claude`, `opencode`, `codex` → mock-claude) to PATH, and hands out +//! shims (`claude`, `opencode`, `codex`, `gemini` → mock-claude) to PATH, and hands out //! connected gRPC service clients. //! //! Build artifacts are picked up from the workspace target dir. Each test gets @@ -374,7 +374,7 @@ fn install_provider_shims(dir: &Path) -> Result<()> { mock.display() ); } - for name in ["claude", "opencode", "codex"] { + for name in ["claude", "opencode", "codex", "gemini"] { let shim = dir.join(exe(name)); #[cfg(unix)] std::os::unix::fs::symlink(&mock, &shim) diff --git a/tests/e2e/tests/gemini_provider.rs b/tests/e2e/tests/gemini_provider.rs new file mode 100644 index 0000000..08d8164 --- /dev/null +++ b/tests/e2e/tests/gemini_provider.rs @@ -0,0 +1,118 @@ +//! E2E: gemini-typed provider spawns correctly and the run reaches completed, +//! over gRPC IPC. + +use anyhow::Result; +use modula_rpc::v1::{ + CreateAgentRequest, CreateProviderRequest, GetProviderRequest, ListRunsForAgentRequest, + TriggerAgentRequest, +}; +use modula_test_support::Harness; +use std::time::{Duration, Instant}; + +use modula_test_support::fixtures as common; + +#[tokio::test] +async fn gemini_provider_agent_completes() -> Result<()> { + let h = Harness::start().await?; + let ws = common::fresh_workspace(&h, "demo").await?; + + let cfg_dir = h.modula_dir.join(".gemini"); + std::fs::create_dir_all(&cfg_dir)?; + let cfg_str = cfg_dir.to_string_lossy().to_string(); + + // Create an gemini-typed provider. + let gm_id = h + .providers() + .create(CreateProviderRequest { + workspace_id: ws.clone(), + name: "Gemini".into(), + r#type: "gemini".into(), + config_dir: cfg_str.clone(), + description: None, + mcp_servers: vec![], + }) + .await? + .into_inner() + .id; + + // Round-trip — type and config_dir survive. + let detail = h + .providers() + .get(GetProviderRequest { + workspace_id: ws.clone(), + provider_id: gm_id.clone(), + }) + .await? + .into_inner(); + assert_eq!(detail.r#type, "gemini"); + assert_eq!(detail.config_dir, cfg_str); + + // Create a manual agent using the gemini provider. + let agent_id = h + .agents() + .create(CreateAgentRequest { + workspace_id: ws.clone(), + name: "gm-agent".into(), + description: "gemini test agent".into(), + provider_id: gm_id, + model: None, + manual: true, + schedule: None, + rules: vec![], + args: vec![], + prompt: "hello from gemini".into(), + spawn_per_variant: false, + skills: vec![], + }) + .await? + .into_inner() + .id; + + // mock-claude keys on MODULA_AGENT_NAME, not argv, so it runs identically + // whether invoked as "claude" or "gemini" — no custom recipe. + + // Trigger a manual run. + h.agents() + .trigger(TriggerAgentRequest { + workspace_id: ws.clone(), + agent_id: agent_id.clone(), + args: None, + }) + .await?; + + // Poll until the run reaches completed (≤ 10s). + let deadline = Instant::now() + Duration::from_secs(10); + let final_run = loop { + let runs = h + .runs() + .list_for_agent(ListRunsForAgentRequest { + workspace_id: ws.clone(), + agent_id: agent_id.clone(), + }) + .await? + .into_inner() + .runs; + if let Some(run) = runs.first() { + if run.status == "completed" || run.status == "failed" { + break Some(run.clone()); + } + } + if Instant::now() >= deadline { + break None; + } + tokio::time::sleep(Duration::from_millis(300)).await; + }; + let final_run = final_run.expect("run did not finish in time"); + assert_eq!(final_run.status, "completed", "run did not complete"); + + // Confirm a log file was written. + let log_path = final_run.log_path.expect("log_path"); + let log_file = h.workspace_path(&ws).join("logs").join(&log_path); + assert!( + log_file.exists(), + "log file missing: {}", + log_file.display() + ); + + Ok(()) +} diff --git a/tests/e2e/tests/provider_mcp.rs b/tests/e2e/tests/provider_mcp.rs index db7a4eb..904c9b0 100644 --- a/tests/e2e/tests/provider_mcp.rs +++ b/tests/e2e/tests/provider_mcp.rs @@ -251,3 +251,51 @@ async fn opencode_mcp_roundtrip() -> Result<()> { assert_eq!(err.code(), Code::InvalidArgument); Ok(()) } + +#[tokio::test] +async fn gemini_mcp_roundtrip() -> Result<()> { + let h = Harness::start().await?; + let ws = common::fresh_workspace(&h, "demo").await?; + let dir = h.modula_dir.join(".gemini"); + std::fs::create_dir_all(&dir)?; + let file = dir.join("settings.json"); + std::fs::write( + &file, + serde_json::to_string_pretty(&json!({ + "general": { "vimMode": true }, + "mcpServers": { + "local-tool": { "command": "node", "args": ["x.js"] } + } + }))?, + )?; + + let id = create_typed( + &h, + &ws, + "p-gemini", + "gemini", + &dir, + vec![mcp("atlassian", ATLASSIAN_URL, Some("tok-1"))], + ) + .await?; + + let servers = get_mcp(&h, &ws, &id).await?; + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, ATLASSIAN_URL); + assert_eq!(servers[0].auth_token.as_deref(), Some("Bearer tok-1")); + + // Streamable HTTP is `httpUrl` (plain `url` means SSE to Gemini). + let disk: Json = serde_json::from_str(&std::fs::read_to_string(&file)?)?; + assert_eq!(disk["general"]["vimMode"], true); + assert_eq!(disk["mcpServers"]["local-tool"]["command"], "node"); + let atl = &disk["mcpServers"]["atlassian"]; + assert_eq!(atl["httpUrl"], ATLASSIAN_URL); + assert!(atl.get("url").is_none()); + assert_eq!(atl["headers"]["Authorization"], "Bearer tok-1"); + + put_mcp(&h, &ws, &id, vec![]).await?; + let disk: Json = serde_json::from_str(&std::fs::read_to_string(&file)?)?; + assert!(disk["mcpServers"].get("atlassian").is_none()); + assert_eq!(disk["mcpServers"]["local-tool"]["command"], "node"); + Ok(()) +} From 6b982480945b16deecbc9d6dd992d45e6b54b8a4 Mon Sep 17 00:00:00 2001 From: Blake Wood Date: Fri, 25 Sep 2026 16:39:51 -0700 Subject: [PATCH 3/5] feat(desktop): add Gemini provider type, icon, and docs --- README.md | 5 +++-- apps/desktop/src/components/icons/GeminiIcon.tsx | 8 ++++++++ .../src/components/provider-form/ProviderFields.tsx | 8 ++++++-- .../src/components/provider-form/useProviderForm.ts | 2 ++ .../components/recommended-tools/RecommendedToolsList.tsx | 2 ++ apps/desktop/src/lib/providerTypes.tsx | 2 ++ crates/modula_services/templates/overview.md | 6 +++--- docs/ARCHITECTURE.md | 4 ++-- docs/MODULA.md | 2 +- 9 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/components/icons/GeminiIcon.tsx diff --git a/README.md b/README.md index 32899b9..25220f2 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Turn tasks into shipped code with agents. ## From Ticket to Shipped Code, Autonomously -Modula is an agentic orchestration platform that automates your development workflow end to end. Bring your own coding agents (Claude, Codex, OpenCode, and others) and let them run the whole thing in one place, instead of jumping between your terminal, IDE, AI agents, and issue tracker. +Modula is an agentic orchestration platform that automates your development workflow end to end. Bring your own coding agents (Claude, Codex, OpenCode, Gemini, and others) and let them run the whole thing in one place, instead of jumping between your terminal, IDE, AI agents, and issue tracker. - **Your whole workflow in one place**: tickets, agents, code, and review, with no tool-switching - **Runs on its own**: agents pick up work automatically as it progresses, not when you prompt them @@ -42,7 +42,7 @@ Modula is an agentic orchestration platform that automates your development work | **Parallel variants** | Explore several solutions to the same task at once, then pick the one you like best | | **Isolated by default** | Each variant works in its own isolated branch, so parallel attempts never collide | | **Configurable workflow** | Tailor the stages a task moves through to fit your process | -| **Bring your own model** | Claude, Codex, and OpenCode work out of the box, and you choose the model for each | +| **Bring your own model** | Claude, Codex, OpenCode, and Gemini work out of the box, and you choose the model for each | | **Local and self-hosted models** | Point a provider at a local or self-hosted model to keep everything on your machine | | **Live progress** | Watch every agent work in real time | | **Works with any tracker** | Pull tasks from Jira, Linear, GitHub Issues, or your own source, kept in sync automatically | @@ -57,6 +57,7 @@ Bring your own coding agents. Supported providers: | [Claude Code](https://github.com/anthropics/claude-code) | Fully supported | | [OpenCode](https://github.com/opencode-ai/opencode) | Fully supported | | [Codex CLI](https://github.com/openai/codex) | Fully supported | +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | Fully supported | | More coming soon | | ## Install diff --git a/apps/desktop/src/components/icons/GeminiIcon.tsx b/apps/desktop/src/components/icons/GeminiIcon.tsx new file mode 100644 index 0000000..398d85d --- /dev/null +++ b/apps/desktop/src/components/icons/GeminiIcon.tsx @@ -0,0 +1,8 @@ +export function GeminiIcon({ className }: { className?: string }) { + return ( + + Gemini + + + ); +} diff --git a/apps/desktop/src/components/provider-form/ProviderFields.tsx b/apps/desktop/src/components/provider-form/ProviderFields.tsx index a3e4928..9328aac 100644 --- a/apps/desktop/src/components/provider-form/ProviderFields.tsx +++ b/apps/desktop/src/components/provider-form/ProviderFields.tsx @@ -52,7 +52,7 @@ export function ProviderFields({ = Object.fromEntries( diff --git a/crates/modula_services/templates/overview.md b/crates/modula_services/templates/overview.md index 2bd95c2..797a405 100644 --- a/crates/modula_services/templates/overview.md +++ b/crates/modula_services/templates/overview.md @@ -375,13 +375,13 @@ The engine is the only spawner of agents. For each invocation (manual, scheduled - Builds the env: - `MODULA_WORKSPACE` — the workspace id (also the directory name) - `MODULA_ENGINE_SOCKET` — path to the engine's local IPC socket (the `modula` CLI resolves it automatically) - - `CLAUDE_CONFIG_DIR` / `OPENCODE_CONFIG_DIR` — the resolved provider's config dir (name depends on provider type) + - `CLAUDE_CONFIG_DIR` / `OPENCODE_CONFIG_DIR` / `CODEX_HOME` / `GEMINI_CLI_HOME` — the resolved provider's config dir (name depends on provider type; `GEMINI_CLI_HOME` is the parent of the `.gemini` dir) - `MODULA_AGENT_EXTRA` — rendered "Inputs for this run" block, built from the agent's declared `args[]` - `MODULA_LOG_TS` — log file naming - `MODULA_LOOP_ITER` + `MODULA_LOOP_TOTAL` — 1-based current iteration and configured total (both `1` for non-looping agents) - - Optional `MODULA_CLAUDE_MODEL` (claude) / `MODULA_PROVIDER_MODEL` (opencode) + - Optional `MODULA_CLAUDE_MODEL` (claude) / `MODULA_PROVIDER_MODEL` (opencode, codex, gemini) - Opens a log file at `/logs/[-]-.log`. -- Spawns the provider binary (e.g. `claude` or `opencode run`) via `ProviderRuntime::build_command`, as a detached child (`setsid`). The process survives engine restarts. +- Spawns the provider binary (e.g. `claude`, `opencode run`, or `gemini -p`) via `ProviderRuntime::build_command`, as a detached child (`setsid`). The process survives engine restarts. Providers are rows in the `providers` table — each entry pairs an opaque `id` (UUID) with a human-readable `name` (e.g. "claude-personal"), a config dir, and a provider type. Many agents can share one provider. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c4d0538..0d7f7a9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,7 +27,7 @@ The engine is split by layer; each crate depends only on the ones below it. Test-only: `crates/modula_mock_claude` (a `mock-claude` binary that replays JSON recipes as stream-json), `crates/modula_test_support` (the `Harness` that boots -an engine and shims `claude`/`opencode`/`codex` onto `PATH`), and `tests/e2e` +an engine and shims `claude`/`opencode`/`codex`/`gemini` onto `PATH`), and `tests/e2e` (one integration binary per `tests/*.rs`). **`apps/engine` depends on `apps/cli`, not the reverse** — the engine ships the @@ -107,7 +107,7 @@ apps/desktop/ 5. **Spawn = pure Rust.** No bash launcher. `services::spawn` invokes the provider binary via `ProviderRuntime::build_command` with provider-specific env (`CLAUDE_CONFIG_DIR` + optional `MODULA_CLAUDE_MODEL`; - `OPENCODE_CONFIG_DIR` / `CODEX_HOME` + optional `MODULA_PROVIDER_MODEL`) plus + `OPENCODE_CONFIG_DIR` / `CODEX_HOME` / `GEMINI_CLI_HOME` + optional `MODULA_PROVIDER_MODEL`) plus common env (`MODULA_WORKSPACE`, `MODULA_ENGINE_SOCKET`, `MODULA_AGENT_NAME`, `MODULA_AGENT_EXTRA`, `MODULA_LOG_TS`, `MODULA_LOOP_ITER`, `MODULA_LOOP_TOTAL`) and `setsid`, so the child survives engine restarts. diff --git a/docs/MODULA.md b/docs/MODULA.md index 84a484b..587ef5e 100644 --- a/docs/MODULA.md +++ b/docs/MODULA.md @@ -12,7 +12,7 @@ See also: [`CLI.md`](CLI.md) for the `modula` command reference, [`PLUGINS.md`]( | **Rust** | Pinned via `rust-toolchain.toml` (channel `1.88`) | | **Node** | Node 20+ with `pnpm` or `npm` for the desktop frontend | | **Git** | 2.20+ (worktree support) | -| **At least one provider** | `claude`, `opencode`, or `codex` on `PATH` with a configured account | +| **At least one provider** | `claude`, `opencode`, `codex`, or `gemini` on `PATH` with a configured account | ## Build from source From 48a4ad3ac982833b08e1c6b718ac900eae0e52ee Mon Sep 17 00:00:00 2001 From: Blake Wood Date: Fri, 25 Sep 2026 16:50:25 -0700 Subject: [PATCH 4/5] refactor(providers): share read_jsonc, fix stale comments --- crates/modula_services/src/generate.rs | 3 ++- crates/modula_services/src/providers/gemini.rs | 14 +------------- crates/modula_services/src/providers/mod.rs | 12 ++++++++++++ crates/modula_services/src/providers/opencode.rs | 14 +------------- crates/modula_test_support/src/lib.rs | 2 +- tests/e2e/tests/gemini_provider.rs | 2 +- 6 files changed, 18 insertions(+), 29 deletions(-) diff --git a/crates/modula_services/src/generate.rs b/crates/modula_services/src/generate.rs index 679ba30..6a0853c 100644 --- a/crates/modula_services/src/generate.rs +++ b/crates/modula_services/src/generate.rs @@ -67,7 +67,8 @@ impl GenerationService { let prompt = compose_prompt(¶ms.instruction, params.field_label.as_deref()); // Only the chat-first command passes Claude's `--include-partial-messages`, - // without which it emits no deltas. codex/opencode return None here. + // without which it emits no deltas. gemini also returns Some (preset + // `--session-id`); codex/opencode return None. let mut cmd = match runtime.build_command_chat_first(&prompt, &Uuid::new_v4().to_string()) { Some(c) => c, None => runtime.build_command(&prompt, None), diff --git a/crates/modula_services/src/providers/gemini.rs b/crates/modula_services/src/providers/gemini.rs index ac03484..2f1e330 100644 --- a/crates/modula_services/src/providers/gemini.rs +++ b/crates/modula_services/src/providers/gemini.rs @@ -6,7 +6,7 @@ use std::process::Command; use serde_json::{json, Value as JsonValue}; -use super::{program, ChatEvent, ProviderRuntime}; +use super::{program, read_jsonc, ChatEvent, ProviderRuntime}; /// `config_dir` is the `.gemini` dir itself; the CLI finds it as `$GEMINI_CLI_HOME/.gemini`. pub struct GeminiRuntime { @@ -97,18 +97,6 @@ impl ProviderRuntime for GeminiRuntime { } } -/// Best-effort JSONC read of `settings.json`; a missing or unparsable file -/// degrades to an empty object. -fn read_jsonc(path: &Path) -> JsonValue { - let Ok(text) = std::fs::read_to_string(path) else { - return JsonValue::Object(Default::default()); - }; - jsonc_parser::parse_to_serde_value(&text, &jsonc_parser::ParseOptions::default()) - .ok() - .flatten() - .unwrap_or(JsonValue::Object(Default::default())) -} - fn gemini_mcp_summary(config_dir: &Path) -> JsonValue { let config_file = config_dir.join("settings.json"); let config_exists = config_file.is_file(); diff --git a/crates/modula_services/src/providers/mod.rs b/crates/modula_services/src/providers/mod.rs index ba612cd..8b478c3 100644 --- a/crates/modula_services/src/providers/mod.rs +++ b/crates/modula_services/src/providers/mod.rs @@ -55,6 +55,18 @@ fn program(name: &str) -> PathBuf { modula_platform::which(name).unwrap_or_else(|| PathBuf::from(name)) } +/// Best-effort JSONC read (comments tolerated); a missing or unparsable file +/// degrades to an empty object. +fn read_jsonc(path: &std::path::Path) -> JsonValue { + let Ok(text) = std::fs::read_to_string(path) else { + return JsonValue::Object(Default::default()); + }; + jsonc_parser::parse_to_serde_value(&text, &jsonc_parser::ParseOptions::default()) + .ok() + .flatten() + .unwrap_or(JsonValue::Object(Default::default())) +} + /// One selectable model for a provider type, as served by the catalog API. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderModel { diff --git a/crates/modula_services/src/providers/opencode.rs b/crates/modula_services/src/providers/opencode.rs index 57f98b6..2b0b10b 100644 --- a/crates/modula_services/src/providers/opencode.rs +++ b/crates/modula_services/src/providers/opencode.rs @@ -6,7 +6,7 @@ use std::process::Command; use serde_json::Value as JsonValue; -use super::{program, BoxFuture, ChatEvent, ProviderModel, ProviderRuntime}; +use super::{program, read_jsonc, BoxFuture, ChatEvent, ProviderModel, ProviderRuntime}; pub struct OpenCodeRuntime { pub config_dir: PathBuf, @@ -139,18 +139,6 @@ fn parse_opencode_models(stdout: &str) -> Vec { .collect() } -/// Best-effort JSONC read (comments tolerated) for opencode's `opencode.jsonc`; -/// a missing or unparsable file degrades to an empty object. -fn read_jsonc(path: &Path) -> JsonValue { - let Ok(text) = std::fs::read_to_string(path) else { - return JsonValue::Object(Default::default()); - }; - jsonc_parser::parse_to_serde_value(&text, &jsonc_parser::ParseOptions::default()) - .ok() - .flatten() - .unwrap_or(JsonValue::Object(Default::default())) -} - fn opencode_mcp_summary(config_dir: &Path) -> JsonValue { let json = config_dir.join("opencode.json"); let config_file = if json.is_file() { diff --git a/crates/modula_test_support/src/lib.rs b/crates/modula_test_support/src/lib.rs index f506693..beadbd2 100644 --- a/crates/modula_test_support/src/lib.rs +++ b/crates/modula_test_support/src/lib.rs @@ -361,7 +361,7 @@ fn exe(name: &str) -> String { } } -/// Install mock-claude as `claude`, `opencode`, and `codex` under `dir`. +/// Install mock-claude as `claude`, `opencode`, `codex`, and `gemini` under `dir`. /// The mock keys on MODULA_AGENT_NAME for recipe lookup, not argv, so one /// binary handles all provider types without change. fn install_provider_shims(dir: &Path) -> Result<()> { diff --git a/tests/e2e/tests/gemini_provider.rs b/tests/e2e/tests/gemini_provider.rs index 08d8164..ca51b22 100644 --- a/tests/e2e/tests/gemini_provider.rs +++ b/tests/e2e/tests/gemini_provider.rs @@ -20,7 +20,7 @@ async fn gemini_provider_agent_completes() -> Result<()> { std::fs::create_dir_all(&cfg_dir)?; let cfg_str = cfg_dir.to_string_lossy().to_string(); - // Create an gemini-typed provider. + // Create a gemini-typed provider. let gm_id = h .providers() .create(CreateProviderRequest { From 832ab4cc5608e21a49560fce34946ab64aa39ec9 Mon Sep 17 00:00:00 2001 From: Blake Wood Date: Fri, 25 Sep 2026 16:51:30 -0700 Subject: [PATCH 5/5] fix(usage): count only uncached Gemini input tokens --- crates/modula_services/src/usage.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/modula_services/src/usage.rs b/crates/modula_services/src/usage.rs index e128a8e..0a8e66b 100644 --- a/crates/modula_services/src/usage.rs +++ b/crates/modula_services/src/usage.rs @@ -53,14 +53,15 @@ pub fn log_summary(path: &Path) -> Option { .and_then(|v| v.as_i64()) .unwrap_or(0) }; - // Gemini reports `stats` instead of `usage`, and no cost. + // Gemini reports `stats` instead of `usage`, and no cost. Its + // `input_tokens` includes `cached`; `input` is the uncached part. if event.get("usage").is_none() { if let Some(stats) = event.get("stats") { return Some(LogSummary { cost_usd: 0.0, duration_ms: int(Some(stats), "duration_ms"), tokens: UsageTokens { - input: int(Some(stats), "input_tokens"), + input: int(Some(stats), "input"), output: int(Some(stats), "output_tokens"), cache_creation: 0, cache_read: int(Some(stats), "cached"), @@ -99,7 +100,7 @@ mod tests { concat!( r#"{"type":"init","session_id":"s1","model":"auto"}"#, "\n", - r#"{"type":"result","status":"success","stats":{"total_tokens":36787,"input_tokens":34727,"output_tokens":144,"cached":512,"input":34727,"duration_ms":12992,"tool_calls":2,"models":{}}}"#, + r#"{"type":"result","status":"success","stats":{"total_tokens":36787,"input_tokens":34727,"output_tokens":144,"cached":512,"input":34215,"duration_ms":12992,"tool_calls":2,"models":{}}}"#, "\n", ), ) @@ -107,7 +108,7 @@ mod tests { let s = log_summary(&path).unwrap(); assert_eq!(s.cost_usd, 0.0); assert_eq!(s.duration_ms, 12992); - assert_eq!(s.tokens.input, 34727); + assert_eq!(s.tokens.input, 34215); assert_eq!(s.tokens.output, 144); assert_eq!(s.tokens.cache_read, 512); }