diff --git a/package.json b/package.json index 985ee9b01c..b6b6ddc549 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test:watch": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", - "server:build": "cd src-tauri && cargo build --release --bin codeg-server --no-default-features", + "server:build": "cd src-tauri && cargo build --bin codeg-server --no-default-features", + "buildall": "next build && cd src-tauri && cargo build --bin codeg-server --no-default-features", "server:dev": "cd src-tauri && cargo run --bin codeg-server --no-default-features", "tauri:prepare-sidecars": "node src-tauri/scripts/prepare-sidecars.mjs", "tauri:before-dev": "pnpm tauri:prepare-sidecars && pnpm dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 469600cb14..bf1005d075 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1053,6 +1053,7 @@ dependencies = [ "ignore", "image", "include_dir", + "indexmap 2.13.0", "insta", "junction", "keyring", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a20d8a7b6a..25e9bcf0c1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -62,6 +62,7 @@ tauri-plugin-dialog = { version = "2", optional = true } async-trait = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" +indexmap = { version = "2", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] } chrono-tz = "0.10" # Host IANA zone name (e.g. `Asia/Shanghai`) — chrono only exposes the offset. diff --git a/src-tauri/src/acp/agent_mentions.rs b/src-tauri/src/acp/agent_mentions.rs index afefff4e5f..bb1a0018aa 100644 --- a/src-tauri/src/acp/agent_mentions.rs +++ b/src-tauri/src/acp/agent_mentions.rs @@ -191,7 +191,8 @@ fn valid_agent_wire_syntax(agent_type: &str) -> bool { } fn parse_internal_agent_routes(candidate: &str) -> Option { - if !candidate.starts_with(ROUTE_FRAME_SEPARATOR) || !candidate.ends_with(ROUTE_FRAME_SEPARATOR) { + if !candidate.starts_with(ROUTE_FRAME_SEPARATOR) || !candidate.ends_with(ROUTE_FRAME_SEPARATOR) + { return None; } parse_internal_agent_routes_body( @@ -835,7 +836,8 @@ mod tests { #[test] fn a_title_is_cut_at_whichever_frame_marker_comes_first() { // Separator intact: the cut lands on it, as it always has. - let mut separated = String::from("hi \u{001e}{\"kind\":\"codeg_internal_agent_routes\",\"ve"); + let mut separated = + String::from("hi \u{001e}{\"kind\":\"codeg_internal_agent_routes\",\"ve"); cut_at_route_frame_marker(&mut separated); assert_eq!(separated, "hi"); diff --git a/src-tauri/src/acp/background_watch.rs b/src-tauri/src/acp/background_watch.rs index 9e13427ae1..85271acdb9 100644 --- a/src-tauri/src/acp/background_watch.rs +++ b/src-tauri/src/acp/background_watch.rs @@ -615,7 +615,12 @@ impl WatchState { return; } let before = self.resolved_title(); - capture_title_record(value, record_type, &mut self.custom_title, &mut self.ai_title); + capture_title_record( + value, + record_type, + &mut self.custom_title, + &mut self.ai_title, + ); let after = self.resolved_title(); // `capture_title_record` ignores blank values, so `after` is `None` // only when nothing has ever been captured — never a clear. @@ -850,8 +855,7 @@ impl WatchState { }); let outstanding = self.tasks.len() as u32; - let accounting_changed = - expired_any || self.last_emitted_outstanding != Some(outstanding); + let accounting_changed = expired_any || self.last_emitted_outstanding != Some(outstanding); if changed_turns.is_empty() && settled.is_empty() && !accounting_changed { return None; } @@ -1056,8 +1060,7 @@ impl WatchState { .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) { - let status = - task.get("status").and_then(|s| s.as_str()).unwrap_or(""); + let status = task.get("status").and_then(|s| s.as_str()).unwrap_or(""); if is_terminal_task_status(status) && self.tasks.remove(id).is_some() { self.settled_ids.insert(id.to_string()); tracing::info!( @@ -1124,8 +1127,7 @@ impl WatchState { // again — the notification's own documents // multi-notify). "SendMessage" => { - let Some(to) = - input.and_then(|i| i.get("to")).and_then(|t| t.as_str()) + let Some(to) = input.and_then(|i| i.get("to")).and_then(|t| t.as_str()) else { continue; }; @@ -1268,8 +1270,7 @@ impl WatchState { // Cosmetic re-basing of the SAME continuous out-of-turn // stretch (not a new initiator record) — the origin carries // over unchanged. - let inherited_origin = - self.episode.as_ref().and_then(|e| e.origin_task_id.clone()); + let inherited_origin = self.episode.as_ref().and_then(|e| e.origin_task_id.clone()); self.episode = Some(Episode { start_offset: self.next_episode_base(), acc: ClaudeRecordAccumulator::new( @@ -1638,9 +1639,7 @@ mod tests { ws.tick(ledger, "/tmp", "conn-test", false, true) } - fn unpack( - event: AcpEvent, - ) -> (Vec, u32, Vec, u64) { + fn unpack(event: AcpEvent) -> (Vec, u32, Vec, u64) { match event { AcpEvent::BackgroundActivity { turns, @@ -1756,7 +1755,8 @@ mod tests { let path = temp_session(&dir); // Fork-time metadata at the head (post-epoch stamp), then the copied // history (original pre-fork stamps), then the genuinely-new prompt. - let queue_op = r#"{"type":"queue-operation","timestamp":"2026-07-07T03:50:00.100Z","uuid":"q-1"}"#; + let queue_op = + r#"{"type":"queue-operation","timestamp":"2026-07-07T03:50:00.100Z","uuid":"q-1"}"#; let copied_user = r#"{"type":"user","timestamp":"2026-07-07T03:46:00.000Z","uuid":"u-hi","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}"#; let copied_asst = r#"{"type":"assistant","timestamp":"2026-07-07T03:46:05.000Z","uuid":"a-hi","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}]}}"#; let new_user = r#"{"type":"user","timestamp":"2026-07-07T03:50:10.000Z","uuid":"u-hello","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}"#; @@ -1801,8 +1801,7 @@ mod tests { // The flush completes: the reconstructed ack must account. append_raw(&path, tail); append_raw(&path, "\n"); - let (_, outstanding, ..) = - unpack(tick_now(&mut ws, &ledger).expect("ack event")); + let (_, outstanding, ..) = unpack(tick_now(&mut ws, &ledger).expect("ack event")); assert_eq!(outstanding, 1, "mid-flush ack must survive discovery"); } @@ -1836,8 +1835,7 @@ mod tests { // ahead of the baseline and re-arms the accounting. ws.rearm("s2".into(), epoch("2026-07-07T03:50:00.000Z")); ws.adopt_file(forked.clone()); - let (_, outstanding, ..) = - unpack(tick_now(&mut ws, &ledger).expect("resume event")); + let (_, outstanding, ..) = unpack(tick_now(&mut ws, &ledger).expect("resume event")); assert_eq!( outstanding, 1, "a resume written before the watcher noticed the fork must re-arm" @@ -1877,8 +1875,7 @@ mod tests { assert_eq!(outstanding, 1, "post-fork resume must re-arm"); write_lines(&forked, &[¬ification("agentX", "completed")]); - let (_, outstanding, settled, _) = - unpack(tick_now(&mut ws, &ledger).unwrap()); + let (_, outstanding, settled, _) = unpack(tick_now(&mut ws, &ledger).unwrap()); assert_eq!(outstanding, 0); assert_eq!(settled.len(), 1); } @@ -1995,8 +1992,7 @@ mod tests { &assistant_text("a2", "resuming"), ], ); - let (turns, ..) = - unpack(tick_prompting(&mut ws, &ledger).expect("turns event")); + let (turns, ..) = unpack(tick_prompting(&mut ws, &ledger).expect("turns event")); assert!( !turns.is_empty(), "an autonomous initiator after the reply is out-of-turn as before" @@ -2106,8 +2102,7 @@ mod tests { &path, &[command, cron, &assistant_text("a1", "Working on it.")], ); - let (turns, ..) = - unpack(tick_prompting(&mut ws, &ledger).expect("turns event")); + let (turns, ..) = unpack(tick_prompting(&mut ws, &ledger).expect("turns event")); assert!( !turns.is_empty(), "a different submission is not this one's side record" @@ -2146,8 +2141,7 @@ mod tests { &assistant_text("a1", "Build finished cleanly."), ], ); - let (turns, ..) = - unpack(tick_prompting(&mut ws, &ledger).expect("settle event")); + let (turns, ..) = unpack(tick_prompting(&mut ws, &ledger).expect("settle event")); // Not just "an episode opened": the settlement's own follow-up is what // has nowhere else to render, so it must be IN the emitted turn. assert!( @@ -2275,8 +2269,7 @@ mod tests { &path, &[&cron_prompt("new pass"), &assistant_text("a9", "hi")], ); - let (turns, outstanding, ..) = - unpack(tick_now(&mut ws, &ledger).expect("turns event")); + let (turns, outstanding, ..) = unpack(tick_now(&mut ws, &ledger).expect("turns event")); assert_eq!(outstanding, 0, "historical ack must NOT register"); assert_eq!(turns.len(), 1); } @@ -2527,11 +2520,18 @@ mod tests { let _ = tick_now(&mut ws, &ledger); write_lines( &path, - &[¬ification("shell1", "completed"), &assistant_text("a1", "Done.")], + &[ + ¬ification("shell1", "completed"), + &assistant_text("a1", "Done."), + ], ); let (turns, outstanding, settled, _) = unpack(tick_now(&mut ws, &ledger).expect("settle event")); - assert_eq!(turns.len(), 1, "a shell follow-up has nowhere else to render"); + assert_eq!( + turns.len(), + 1, + "a shell follow-up has nowhere else to render" + ); assert_eq!(outstanding, 0); assert_eq!( settled.len(), @@ -2612,8 +2612,7 @@ mod tests { &assistant_text("a1", "Working on it."), ], ); - let (turns, ..) = - unpack(tick_prompting(&mut ws, &ledger).expect("turns event")); + let (turns, ..) = unpack(tick_prompting(&mut ws, &ledger).expect("turns event")); assert_eq!( turns.len(), 1, @@ -2648,8 +2647,7 @@ mod tests { &assistant_text("a1", "Build finished cleanly."), ], ); - let (turns, ..) = - unpack(tick_prompting(&mut ws, &ledger).expect("settle event")); + let (turns, ..) = unpack(tick_prompting(&mut ws, &ledger).expect("settle event")); assert_eq!( turns.len(), 1, @@ -2810,7 +2808,10 @@ mod tests { let more = assistant_text("a2", "step two"); let (head, tail) = more.split_at(more.len() / 2); { - let mut f = std::fs::OpenOptions::new().append(true).open(&path).unwrap(); + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); f.write_all(head.as_bytes()).unwrap(); } assert!( @@ -2819,7 +2820,10 @@ mod tests { ); { - let mut f = std::fs::OpenOptions::new().append(true).open(&path).unwrap(); + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); f.write_all(tail.as_bytes()).unwrap(); f.write_all(b"\n").unwrap(); } @@ -2903,7 +2907,9 @@ mod tests { // cron prompt (isMeta + string): initiates with the prompt text. let cron: serde_json::Value = serde_json::from_str(&cron_prompt("check weather")).unwrap(); assert_eq!( - turn_initiator_text(&cron).as_ref().map(|text| text.as_str()), + turn_initiator_text(&cron) + .as_ref() + .map(|text| text.as_str()), Some("check weather") ); @@ -2965,7 +2971,10 @@ mod tests { write_lines(&path, &[&ai_title("Fix the login flow")]); let _ = tick_now(&mut ws, &ledger); - assert_eq!(ws.pending_title.take().as_deref(), Some("Fix the login flow")); + assert_eq!( + ws.pending_title.take().as_deref(), + Some("Fix the login flow") + ); // Claude Code re-emits the record; the resolved name did not change. write_lines(&path, &[&ai_title("Fix the login flow")]); @@ -2975,7 +2984,10 @@ mod tests { // A genuinely new name is queued again. write_lines(&path, &[&ai_title("Fix the signup flow")]); let _ = tick_now(&mut ws, &ledger); - assert_eq!(ws.pending_title.take().as_deref(), Some("Fix the signup flow")); + assert_eq!( + ws.pending_title.take().as_deref(), + Some("Fix the signup flow") + ); } /// `customTitle ?? aiTitle` — Claude Code's own precedence, and the one @@ -2996,8 +3008,7 @@ mod tests { write_lines(&path, &[&ai_title("Concise AI Summary")]); let _ = tick_now(&mut ws, &ledger); assert_eq!( - ws.pending_title, - None, + ws.pending_title, None, "the generated title must not displace the user's own name" ); assert_eq!(ws.resolved_title().as_deref(), Some("auth-refactor")); diff --git a/src-tauri/src/acp/binary_cache.rs b/src-tauri/src/acp/binary_cache.rs index 9e3dcfd711..103d924fa6 100644 --- a/src-tauri/src/acp/binary_cache.rs +++ b/src-tauri/src/acp/binary_cache.rs @@ -482,8 +482,9 @@ pub async fn ensure_binary_for_agent_with_progress( /// Hex SHA-256 of a file, streamed so a large archive never lands in memory. fn file_sha256(path: &std::path::Path) -> Result { use sha2::{Digest, Sha256}; - let mut file = std::fs::File::open(path) - .map_err(|e| AcpError::DownloadFailed(format!("failed to open archive for hashing: {e}")))?; + let mut file = std::fs::File::open(path).map_err(|e| { + AcpError::DownloadFailed(format!("failed to open archive for hashing: {e}")) + })?; let mut hasher = Sha256::new(); let mut buf = vec![0u8; 64 * 1024]; loop { diff --git a/src-tauri/src/acp/codex_goal.rs b/src-tauri/src/acp/codex_goal.rs index 859661a72d..fc64931269 100644 --- a/src-tauri/src/acp/codex_goal.rs +++ b/src-tauri/src/acp/codex_goal.rs @@ -296,10 +296,7 @@ mod tests { }); let m = goal_marker(&goal).expect("goal marker"); assert_eq!(m.tool_name, "update_goal"); - assert_eq!( - m.title, - "Goal updated (budget_limited): Fix the login bug" - ); + assert_eq!(m.title, "Goal updated (budget_limited): Fix the login bug"); let out: Value = serde_json::from_str(&m.output_json).unwrap(); assert_eq!(out["goal"]["status"], "budget_limited"); assert_eq!(out["goal"]["tokensUsed"], 5200); @@ -380,9 +377,11 @@ mod tests { #[test] fn active_then_clear_closes_one_card_with_objective() { let mut open = None; - let create = - next_goal_marker(&mut open, &json!({ "objective": "Ship it", "status": "active" })) - .unwrap(); + let create = next_goal_marker( + &mut open, + &json!({ "objective": "Ship it", "status": "active" }), + ) + .unwrap(); assert_eq!(create.tool_name, "create_goal"); assert_eq!(open.as_deref(), Some("Ship it")); @@ -399,9 +398,11 @@ mod tests { // a no-op (no stray standalone "complete" card). let mut open = None; next_goal_marker(&mut open, &json!({ "objective": "Y", "status": "active" })).unwrap(); - let terminal = - next_goal_marker(&mut open, &json!({ "objective": "Y", "status": "budgetLimited" })) - .unwrap(); + let terminal = next_goal_marker( + &mut open, + &json!({ "objective": "Y", "status": "budgetLimited" }), + ) + .unwrap(); assert_eq!(terminal.tool_name, "update_goal"); assert_eq!(status_of(&terminal), "budget_limited"); assert_eq!(open, None); diff --git a/src-tauri/src/acp/codex_model_catalog.rs b/src-tauri/src/acp/codex_model_catalog.rs index e25db4152f..9744a847dd 100644 --- a/src-tauri/src/acp/codex_model_catalog.rs +++ b/src-tauri/src/acp/codex_model_catalog.rs @@ -77,7 +77,13 @@ fn enum_spec_for(key: &str) -> Option { "default_reasoning_summary" => spec(&["auto", "concise", "detailed", "none"], false), "default_verbosity" => spec(&["low", "medium", "high"], true), "shell_type" => spec( - &["default", "local", "unified_exec", "disabled", "shell_command"], + &[ + "default", + "local", + "unified_exec", + "disabled", + "shell_command", + ], false, ), // Only `freeform` is a variant; `function` would reject the whole catalog. @@ -215,7 +221,11 @@ pub fn bundled_snapshot_models() -> Vec { pub fn fallback_base_slug(snapshot: &[Value]) -> Option { snapshot .iter() - .min_by_key(|m| m.get("priority").and_then(Value::as_i64).unwrap_or(i64::MAX)) + .min_by_key(|m| { + m.get("priority") + .and_then(Value::as_i64) + .unwrap_or(i64::MAX) + }) .and_then(|m| m.get("slug").and_then(Value::as_str)) .map(str::to_owned) } @@ -228,27 +238,21 @@ fn is_listable(model: &Value) -> bool { model.get("visibility").and_then(Value::as_str) == Some("list") } -/// Expand a compact config into a full `{"models":[ ModelInfo, ... ]}` catalog. +/// Build the per-custom ModelInfo objects for an expansion. Each custom +/// clones its declared `base` snapshot entry (falling back to the +/// highest-priority entry when the base is missing or the snapshot is +/// unavailable), applies **sanitized** overrides, and is forced +/// `visibility:"list"` + `supported_in_api:true` + `upgrade:null` so a custom +/// always lands as a picker-visible model codex can target. Priority is +/// inherited from the base — the caller decides whether to renumber. /// -/// Because `model_catalog_json` is a whole-table replace, the output contains -/// **all** official models (verbatim, so codex's hidden entries such as -/// `codex-auto-review` stay hidden) minus the ones the user removed, plus the -/// user's custom entries. Custom entries clone their `base` snapshot ModelInfo -/// (falling back to the highest-priority entry when `base` is unknown), apply -/// **sanitized** overrides, and are forced `visibility:"list"` + -/// `supported_in_api:true`. Priority is renumbered by final order (customs -/// first) so the picker ordering is deterministic without colliding official -/// priorities. -pub fn expand_to_catalog(config: &CodexModelConfig, snapshot: &[Value]) -> Value { - let excluded: HashSet<&str> = config - .excluded_officials - .iter() - .map(String::as_str) - .collect(); +/// This is the shared core of [`expand_to_catalog`] (customs + officials) and +/// [`expand_customs_only`] (customs only). Keeping it in one place guarantees +/// the per-custom fields, sanitization, and force-set flags stay in lockstep +/// across the two entry points. +fn build_customs(config: &CodexModelConfig, snapshot: &[Value]) -> Vec { let fallback = fallback_base_slug(snapshot); - let mut out: Vec = Vec::with_capacity(config.customs.len() + snapshot.len()); - - // Customs first — surface the user's own models at the top of the picker. + let mut out: Vec = Vec::with_capacity(config.customs.len()); for c in &config.customs { let base = snapshot .iter() @@ -282,6 +286,27 @@ pub fn expand_to_catalog(config: &CodexModelConfig, snapshot: &[Value]) -> Value obj.insert("upgrade".into(), Value::Null); out.push(Value::Object(obj)); } + out +} + +/// Expand a compact config into a full `{"models":[ ModelInfo, ... ]}` catalog. +/// +/// Because `model_catalog_json` is a whole-table replace, the output contains +/// **all** official models (verbatim, so codex's hidden entries such as +/// `codex-auto-review` stay hidden) minus the ones the user removed, plus the +/// user's custom entries. Custom entries clone their `base` snapshot ModelInfo +/// (falling back to the highest-priority entry when `base` is unknown), apply +/// **sanitized** overrides, and are forced `visibility:"list"` + +/// `supported_in_api:true`. Priority is renumbered by final order (customs +/// first) so the picker ordering is deterministic without colliding official +/// priorities. +pub fn expand_to_catalog(config: &CodexModelConfig, snapshot: &[Value]) -> Value { + let excluded: HashSet<&str> = config + .excluded_officials + .iter() + .map(String::as_str) + .collect(); + let mut out = build_customs(config, snapshot); // Then every official verbatim, minus the ones the user removed. A removal // only applies to models codex actually *lists*: codex retires a model by @@ -307,6 +332,21 @@ pub fn expand_to_catalog(config: &CodexModelConfig, snapshot: &[Value]) -> Value Value::Object(Map::from_iter([("models".to_string(), Value::Array(out))])) } +/// Expand a compact config into a `{"models":[ ModelInfo, ... ]}` catalog +/// containing **only** the user's customs — no auto-included officials and no +/// priority renumbering. Use when the caller has already determined the +/// relevant model set (e.g. a per-conversation Codex workspace where only the +/// selected model should be listed). +/// +/// The customs still inherit the same sanitization, base-cloning, and force-set +/// flags as [`expand_to_catalog`], so the only difference is what the surrounding +/// list contains. An empty `customs` produces `{"models": []}` rather than +/// `None` — the caller is responsible for treating the empty case as "feature +/// off" if that's the right semantics (see [`is_effectively_empty`]). +pub fn expand_customs_only(config: &CodexModelConfig, snapshot: &[Value]) -> Value { + let out = build_customs(config, snapshot); + Value::Object(Map::from_iter([("models".to_string(), Value::Array(out))])) +} /// The default model slug written as codex's root `model`: the explicit /// `default` when it names a listed model, else the first custom, else the first /// non-excluded listable official, else `None`. @@ -332,9 +372,7 @@ pub fn default_slug(config: &CodexModelConfig, snapshot: &[Value]) -> Option