diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index 44a53ff4c09..2217b6d8303 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -1727,6 +1727,8 @@ pub struct AcpAgentInfo { pub host_tools_agent_mode: bool, pub config_json: Option, pub config_file_path: Option, + pub system_instructions_file_path: Option, + pub system_instructions_content: Option, pub codex_auth_json: Option, pub codex_config_toml: Option, /// Compact structured codex model-catalog source (the `codeg` custom-model diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 7e2a47f817e..6c1abd65828 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -9223,6 +9223,220 @@ fn qoder_settings_json_path() -> PathBuf { crate::parsers::qoder::resolve_qoder_config_dir().join("settings.json") } + +/// The user-global instructions file the agent itself loads at session start, +/// or `None` when it has no such file codeg can edit safely. +/// +/// Each entry is the file the agent's shipped code reads (checked against the +/// pinned releases), resolved with the same environment overrides as the rest +/// of this panel. `None` on purpose: +/// - Hermes: its only global file is `SOUL.md`, which REPLACES the persona; +/// - Cursor: user rules are `.mdc` files with front matter, or server-side; +/// - MiniMax Code: the data dir moves with profiles and a legacy migration; +/// - OpenClaw: the file lives in a workspace configured in openclaw.json, and +/// may be on a remote Gateway; +/// - custom agents: codeg does not know their file layout. +pub(crate) fn agent_system_instructions_path(agent_type: AgentType) -> Option { + match agent_type { + AgentType::ClaudeCode => { + Some(crate::parsers::claude::resolve_claude_config_dir().join("CLAUDE.md")) + } + // `AGENTS.override.md` wins while it is non-blank. + AgentType::Codex => Some(codex_style_instructions_file( + &crate::parsers::codex::resolve_codex_home_dir(), + )), + AgentType::Gemini => { + Some(crate::parsers::gemini::resolve_gemini_base_dir().join("GEMINI.md")) + } + // Antigravity reads every one of `~/.gemini/{AGENTS,GEMINI}.md` from a + // hardcoded `~/.gemini` (it ignores `GEMINI_HOME`); `AGENTS.md` keeps + // it apart from Gemini CLI's `GEMINI.md`. + AgentType::Antigravity => Some(home_dir_or_default().join(".gemini").join("AGENTS.md")), + AgentType::OpenCode => Some( + crate::acp::opencode_config::config_dir() + .unwrap_or_else(|| home_dir_or_default().join(".config").join("opencode")) + .join("AGENTS.md"), + ), + // Cline's "Global AGENTS.md" is the cross-agent `~/.agents` one. + AgentType::Cline => Some(home_dir_or_default().join(".agents").join("AGENTS.md")), + // User memory is `/CODEBUDDY.md` (or `.mdc`, first found); + // an `AGENTS.md` there is only read when the workspace IS the home. + AgentType::CodeBuddy => Some(first_existing_file( + &crate::parsers::codebuddy::resolve_codebuddy_config_dir(), + CODEBUDDY_INSTRUCTIONS_FILES, + )), + AgentType::KimiCode => { + Some(crate::parsers::kimi_code::resolve_kimi_code_home_dir().join("AGENTS.md")) + } + // Pi loads the first of these that exists. + AgentType::Pi => Some(first_existing_file( + &crate::parsers::pi::resolve_pi_agent_dir(), + PI_INSTRUCTIONS_FILES, + )), + AgentType::Grok => Some(crate::parsers::grok::resolve_grok_home_dir().join("AGENTS.md")), + AgentType::DeepSeek => { + Some(crate::parsers::deepseek::resolve_dsh_home_dir().join("AGENTS.md")) + } + AgentType::Qoder => { + Some(crate::parsers::qoder::resolve_qoder_config_dir().join("AGENTS.md")) + } + // Kiro's global instructions are steering files. + AgentType::Kiro => Some( + crate::parsers::kiro::resolve_kiro_home_dir() + .join("steering") + .join("AGENTS.md"), + ), + // A Codex fork: same override precedence. + AgentType::Trae => Some(codex_style_instructions_file( + &crate::acp::trae::resolve_trae_home_dir(), + )), + AgentType::CommandCode => { + Some(crate::acp::command_code::command_code_home_dir().join("AGENTS.md")) + } + // ZCode follows HOME / USERPROFILE only. + AgentType::ZCode => Some(inherited_node_home_dir().join(".zcode").join("AGENTS.md")), + AgentType::Hermes + | AgentType::Cursor + | AgentType::MiniMaxCode + | AgentType::OpenClaw + | AgentType::Custom(_) => None, + } +} + +const CODEX_INSTRUCTIONS_FILES: &[&str] = &["AGENTS.override.md", "AGENTS.md"]; +const PI_INSTRUCTIONS_FILES: &[&str] = + &["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]; +const CODEBUDDY_INSTRUCTIONS_FILES: &[&str] = &["CODEBUDDY.md", "CODEBUDDY.mdc"]; + +/// Every file [`agent_system_instructions_path`] may resolve to for the agent, +/// whichever is in effect right now. A save goes to the file the panel showed +/// (when it is one of these), not to a fresh resolution: blanking Codex's +/// override, say, flips the resolution to `AGENTS.md`, whose content the +/// editor never displayed. +fn agent_system_instructions_candidates(agent_type: AgentType) -> Vec { + let Some(resolved) = agent_system_instructions_path(agent_type) else { + return Vec::new(); + }; + let siblings: &[&str] = match agent_type { + AgentType::Codex | AgentType::Trae => CODEX_INSTRUCTIONS_FILES, + AgentType::Pi => PI_INSTRUCTIONS_FILES, + AgentType::CodeBuddy => CODEBUDDY_INSTRUCTIONS_FILES, + _ => &[], + }; + let mut candidates = vec![resolved.clone()]; + if let Some(dir) = resolved.parent() { + candidates.extend(siblings.iter().map(|name| dir.join(name))); + } + candidates +} + +/// `/AGENTS.override.md` while it holds anything but whitespace, else +/// `/AGENTS.md` — the file Codex (and its forks) actually loads. +fn codex_style_instructions_file(dir: &Path) -> PathBuf { + let override_file = dir.join(CODEX_INSTRUCTIONS_FILES[0]); + match fs::read_to_string(&override_file) { + Ok(text) if !text.trim().is_empty() => override_file, + _ => dir.join(CODEX_INSTRUCTIONS_FILES[1]), + } +} + +/// The first of `names` in `dir` that is a file, else the first name. +fn first_existing_file(dir: &Path, names: &[&str]) -> PathBuf { + names + .iter() + .map(|name| dir.join(name)) + .find(|path| path.is_file()) + .unwrap_or_else(|| dir.join(names[0])) +} + +/// The agent's global instructions file as `(path, content)` for the settings +/// panel; the content is `None` when the file does not exist or cannot be read +/// as UTF-8 text. +pub(crate) fn load_agent_system_instructions( + agent_type: AgentType, +) -> (Option, Option) { + let path = agent_system_instructions_path(agent_type); + let path_str = path.as_ref().map(|p| p.display().to_string()); + let content = path.and_then(|p| fs::read_to_string(p).ok()); + (path_str, content) +} + +/// Write `content` as the agent's global instructions file. +/// +/// The file belongs to the agent (and to the user, who may edit it by hand or +/// keep it in a dotfiles checkout), so the write is careful with it: +/// - content equal to what is on disk is not written at all, and empty content +/// for a missing file does not create it (nor its directory); +/// - a file that exists but cannot be read as text is refused rather than +/// replaced, since the panel showed it empty; +/// - the write is atomic and goes through a symlink, keeping the link and the +/// file's permissions. +/// +/// `shown_path` is the file the panel displayed (the `system_instructions_file_path` +/// it was given); the write goes there, and only when it is one of the +/// agent's own instruction files. Without it the file is resolved afresh. +fn persist_agent_system_instructions( + agent_type: AgentType, + content: &str, + shown_path: Option<&str>, +) -> Result<(), AcpError> { + let Some(resolved) = agent_system_instructions_path(agent_type) else { + return Err(AcpError::protocol( + "this agent has no global instructions file", + )); + }; + let path = instructions_write_target( + resolved, + agent_system_instructions_candidates(agent_type), + shown_path, + )?; + write_instructions_file(&path, content) +} + +/// The file a save writes: the one the panel showed when it is among the +/// agent's `candidates`, else (nothing shown) the `resolved` one. +fn instructions_write_target( + resolved: PathBuf, + candidates: Vec, + shown_path: Option<&str>, +) -> Result { + let Some(shown) = shown_path else { + return Ok(resolved); + }; + candidates + .into_iter() + .find(|candidate| candidate.display().to_string() == shown) + .ok_or_else(|| { + AcpError::protocol(format!( + "{shown} is not this agent's instructions file (it is now {}); reopen the settings to edit it", + resolved.display() + )) + }) +} + +fn write_instructions_file(path: &Path, content: &str) -> Result<(), AcpError> { + let current = match fs::read_to_string(path) { + Ok(text) => Some(text), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + return Err(AcpError::protocol(format!( + "failed to read instructions file {}: {e}", + path.display() + ))) + } + }; + if current.as_deref().unwrap_or_default() == content { + return Ok(()); + } + crate::acp::opencode_config::write_atomic(path, content).map_err(|e| { + AcpError::protocol(format!( + "failed to write instructions file {}: {e}", + path.display() + )) + }) +} + + pub(crate) fn load_agent_local_config_json(agent_type: AgentType) -> Option { if agent_type == AgentType::Codex { return load_codex_local_config_json(); @@ -12534,6 +12748,8 @@ pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result Result, + system_instructions_content: Option, + system_instructions_file_path: Option, codex_auth_json: Option, codex_config_toml: Option, codex_config_toml_baseline: Option, @@ -13011,6 +13231,31 @@ pub(crate) async fn acp_update_agent_config_core( } } + if let Some(instructions) = system_instructions_content.as_deref() { + persist_agent_system_instructions( + agent_type, + instructions, + system_instructions_file_path.as_deref(), + )?; + // The instructions panel saves on its own: nothing else was sent, so + // no native config file may be touched — the generic tail below would + // re-serialize the agent's settings file (and reset it to `{}` when it + // does not parse). + if config_json.is_none() + && codex_auth_json.is_none() + && codex_config_toml.is_none() + && codex_model_catalog.is_none() + && codex_sandbox.is_none() + && grok_config_toml.is_none() + && grok_structured.is_none() + && cursor_cli_config_json.is_none() + && cursor_structured.is_none() + { + emit_acp_agents_updated(emitter, "config_updated", Some(agent_type)); + return Ok(()); + } + } + if agent_type == AgentType::Codex { // Every write to codex's files goes through the commit coordinator, // which owns the keys codeg manages (`model_catalog_json`, and root @@ -13137,6 +13382,8 @@ pub(crate) async fn acp_update_agent_config_core( pub(crate) async fn acp_update_agent_config_and_refresh( agent_type: AgentType, config_json: Option, + system_instructions_content: Option, + system_instructions_file_path: Option, codex_auth_json: Option, codex_config_toml: Option, codex_config_toml_baseline: Option, @@ -13154,6 +13401,8 @@ pub(crate) async fn acp_update_agent_config_and_refresh( acp_update_agent_config_core( agent_type, config_json, + system_instructions_content, + system_instructions_file_path, codex_auth_json, codex_config_toml, codex_config_toml_baseline, @@ -13176,6 +13425,8 @@ pub(crate) async fn acp_update_agent_config_and_refresh( pub async fn acp_update_agent_config( agent_type: AgentType, config_json: Option, + system_instructions_content: Option, + system_instructions_file_path: Option, codex_auth_json: Option, codex_config_toml: Option, codex_config_toml_baseline: Option, @@ -13198,6 +13449,8 @@ pub async fn acp_update_agent_config( acp_update_agent_config_and_refresh( agent_type, config_json, + system_instructions_content, + system_instructions_file_path, codex_auth_json, codex_config_toml, codex_config_toml_baseline, @@ -23205,4 +23458,153 @@ model = "gpt" ); assert_eq!(env.get("CLINE_PROVIDER").map(String::as_str), Some("cline-pass")); } + + #[test] + fn instructions_write_creates_the_file_and_its_directory() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("agent").join("AGENTS.md"); + write_instructions_file(&path, "# Rules\n").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "# Rules\n"); + write_instructions_file(&path, "").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), ""); + } + + #[test] + fn instructions_write_of_empty_text_never_creates_a_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let agent_dir = dir.path().join("agent"); + write_instructions_file(&agent_dir.join("AGENTS.md"), "").unwrap(); + assert!(!agent_dir.exists()); + } + + #[test] + fn instructions_write_refuses_a_file_it_cannot_read_as_text() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("AGENTS.md"); + fs::write(&path, [0xff, 0xfe, 0x00]).unwrap(); + assert!(write_instructions_file(&path, "# Rules").is_err()); + assert_eq!(fs::read(&path).unwrap(), vec![0xff, 0xfe, 0x00]); + } + + #[cfg(unix)] + #[test] + fn instructions_write_goes_through_a_symlink() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("dotfiles-CLAUDE.md"); + fs::write(&target, "old").unwrap(); + let link = dir.path().join("CLAUDE.md"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + write_instructions_file(&link, "new").unwrap(); + assert!(fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + assert_eq!(fs::read_to_string(&target).unwrap(), "new"); + } + + #[test] + fn test_agent_system_instructions_paths() { + let file_name = |agent: AgentType| { + agent_system_instructions_path(agent) + .and_then(|path| path.file_name().map(|n| n.to_string_lossy().into_owned())) + }; + assert_eq!(file_name(AgentType::ClaudeCode).as_deref(), Some("CLAUDE.md")); + assert_eq!(file_name(AgentType::Gemini).as_deref(), Some("GEMINI.md")); + for agent in [ + AgentType::Antigravity, + AgentType::OpenCode, + AgentType::Cline, + AgentType::KimiCode, + AgentType::Grok, + AgentType::DeepSeek, + AgentType::Qoder, + AgentType::Kiro, + AgentType::CommandCode, + AgentType::ZCode, + ] { + assert_eq!(file_name(agent).as_deref(), Some("AGENTS.md"), "{agent:?}"); + } + let antigravity = agent_system_instructions_path(AgentType::Antigravity).unwrap(); + assert!(antigravity.ends_with(".gemini/AGENTS.md")); + let kiro = agent_system_instructions_path(AgentType::Kiro).unwrap(); + assert!(kiro.ends_with("steering/AGENTS.md")); + let cline = agent_system_instructions_path(AgentType::Cline).unwrap(); + assert!(cline.ends_with(".agents/AGENTS.md")); + // Codex, Trae, Pi and CodeBuddy pick among candidates that exist on + // this machine; their rules are covered by the helpers' tests. + for agent in [AgentType::Codex, AgentType::Trae, AgentType::Pi, AgentType::CodeBuddy] { + assert!(agent_system_instructions_path(agent).is_some(), "{agent:?}"); + } + for agent in [ + AgentType::Hermes, + AgentType::Cursor, + AgentType::MiniMaxCode, + AgentType::OpenClaw, + AgentType::Custom("my-bot"), + ] { + assert_eq!(agent_system_instructions_path(agent), None, "{agent:?}"); + } + } + + #[test] + fn codex_style_instructions_use_a_non_blank_override() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(codex_style_instructions_file(dir.path()), dir.path().join("AGENTS.md")); + fs::write(dir.path().join("AGENTS.override.md"), " \n").unwrap(); + assert_eq!(codex_style_instructions_file(dir.path()), dir.path().join("AGENTS.md")); + fs::write(dir.path().join("AGENTS.override.md"), "# Override").unwrap(); + assert_eq!( + codex_style_instructions_file(dir.path()), + dir.path().join("AGENTS.override.md") + ); + } + + #[test] + fn instructions_save_stays_on_the_shown_file_when_the_resolution_flips() { + let dir = tempfile::tempdir().unwrap(); + let override_file = dir.path().join("AGENTS.override.md"); + let agents_file = dir.path().join("AGENTS.md"); + fs::write(&override_file, "# Override").unwrap(); + fs::write(&agents_file, "# Fallback").unwrap(); + let candidates = || vec![override_file.clone(), agents_file.clone()]; + let shown = override_file.display().to_string(); + + // Blanking the shown override flips Codex's resolution to AGENTS.md… + write_instructions_file(&override_file, "").unwrap(); + let resolved = codex_style_instructions_file(dir.path()); + assert_eq!(resolved, agents_file); + // …but the next save still lands on the file the editor showed. + let target = instructions_write_target(resolved, candidates(), Some(&shown)).unwrap(); + assert_eq!(target, override_file); + write_instructions_file(&target, "# New").unwrap(); + assert_eq!(fs::read_to_string(&agents_file).unwrap(), "# Fallback"); + assert_eq!(fs::read_to_string(&override_file).unwrap(), "# New"); + } + + #[test] + fn instructions_save_refuses_a_path_that_is_not_the_agents_file() { + let dir = tempfile::tempdir().unwrap(); + let agents_file = dir.path().join("AGENTS.md"); + let elsewhere = dir.path().join("elsewhere.md").display().to_string(); + assert!(instructions_write_target( + agents_file.clone(), + vec![agents_file.clone()], + Some(&elsewhere) + ) + .is_err()); + assert_eq!( + instructions_write_target(agents_file.clone(), vec![agents_file.clone()], None) + .unwrap(), + agents_file + ); + } + + #[test] + fn first_existing_instructions_file_falls_back_to_the_first_name() { + let dir = tempfile::tempdir().unwrap(); + let names = ["AGENTS.override.md", "AGENTS.md", "CLAUDE.md"]; + assert_eq!( + first_existing_file(dir.path(), &names), + dir.path().join("AGENTS.override.md") + ); + fs::write(dir.path().join("CLAUDE.md"), "x").unwrap(); + assert_eq!(first_existing_file(dir.path(), &names), dir.path().join("CLAUDE.md")); + } } diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 73ebfc6b2ce..93bde52b87f 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -710,6 +710,9 @@ pub async fn acp_update_agent_env( pub struct AcpUpdateAgentConfigParams { pub agent_type: AgentType, pub config_json: Option, + pub system_instructions_content: Option, + #[serde(default)] + pub system_instructions_file_path: Option, pub codex_auth_json: Option, pub codex_config_toml: Option, #[serde(default)] @@ -730,6 +733,8 @@ pub async fn acp_update_agent_config( let affected = acp_commands::acp_update_agent_config_and_refresh( params.agent_type, params.config_json, + params.system_instructions_content, + params.system_instructions_file_path, params.codex_auth_json, params.codex_config_toml, params.codex_config_toml_baseline, diff --git a/src/components/settings/acp-agent-settings.system-instructions.test.tsx b/src/components/settings/acp-agent-settings.system-instructions.test.tsx new file mode 100644 index 00000000000..fb564695421 --- /dev/null +++ b/src/components/settings/acp-agent-settings.system-instructions.test.tsx @@ -0,0 +1,246 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import enMessages from "@/i18n/messages/en.json" +import { + acpListAgents, + acpUpdateAgentConfig, + acpUpdateAgentEnv, + listModelProviders, +} from "@/lib/api" +import type { AcpAgentInfo } from "@/lib/types" +import { + AcpAgentSettings, + rebaseSystemInstructionsDraft, +} from "./acp-agent-settings" + +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal>() + return Object.fromEntries( + Object.entries(actual).map(([name, value]) => [ + name, + typeof value === "function" ? vi.fn(() => new Promise(() => {})) : value, + ]) + ) +}) +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), +})) + +function makeAgent(overrides: Partial = {}): AcpAgentInfo { + return { + agent_type: "gemini", + name: "Gemini", + registry_id: "gemini", + registry_version: "0.1.0", + installed_version: "0.1.0", + description: "Google Gemini CLI agent", + skills_capable: true, + supports_custom_version: false, + available: true, + distribution_type: "npm", + is_acp_adapter: false, + enabled: true, + sort_order: 0, + env: {}, + config_json: null, + config_file_path: null, + system_instructions_file_path: "C:/Users/test/.gemini/GEMINI.md", + system_instructions_content: "# You are a helpful assistant", + codex_auth_json: null, + codex_config_toml: null, + codex_model_catalog: null, + codex_sandbox_settings: null, + cline_secrets_json: null, + hermes_config_yaml: null, + grok_config_toml: null, + grok_settings: null, + cursor_cli_config_json: null, + cursor_settings: null, + model_provider_id: null, + host_tools_agent_mode: false, + custom_source: null, + icon_url: null, + ...overrides, + } +} + +// Claude Code's panel is the one whose shared save also carries the +// instructions; the other panels save them with the card's own button. +function claudeAgent(): AcpAgentInfo { + return makeAgent({ + agent_type: "claude_code", + name: "Claude Code", + registry_id: "claude-acp", + system_instructions_file_path: "/home/test/.claude/CLAUDE.md", + system_instructions_content: "# Be brief", + }) +} + +function renderSettings() { + render( + + + + ) +} + +describe("System Instructions in ACP agent settings", () => { + beforeEach(() => { + vi.mocked(acpUpdateAgentConfig).mockReset() + }) + + it("renders system instructions with path and content, and allows saving", async () => { + const agent = makeAgent() + vi.mocked(acpListAgents).mockResolvedValue([agent]) + vi.mocked(listModelProviders).mockResolvedValue([]) + vi.mocked(acpUpdateAgentConfig).mockResolvedValue(0) + + render( + + + + ) + + await screen.findByRole("heading", { name: "Gemini" }) + + // Check system instructions section + expect(screen.getByText("System Instructions")).toBeInTheDocument() + expect( + screen.getByText("C:/Users/test/.gemini/GEMINI.md") + ).toBeInTheDocument() + + const instructionsInput = screen.getByPlaceholderText( + enMessages.AcpAgentSettings.systemInstructions.placeholder + ) + expect(instructionsInput).toHaveValue("# You are a helpful assistant") + + // Edit instructions + fireEvent.change(instructionsInput, { + target: { value: "# Updated guidelines\nAlways write tests." }, + }) + + // Click save system instructions + const saveButton = screen.getByRole("button", { + name: enMessages.AcpAgentSettings.actions.saveSystemInstructions, + }) + fireEvent.click(saveButton) + + await waitFor(() => expect(acpUpdateAgentConfig).toHaveBeenCalledTimes(1)) + expect(acpUpdateAgentConfig).toHaveBeenCalledWith("gemini", { + system_instructions_content: "# Updated guidelines\nAlways write tests.", + // Bound to the file the editor showed, not a fresh resolution. + system_instructions_file_path: "C:/Users/test/.gemini/GEMINI.md", + }) + }) + + it("does not render system instructions section when path is null", async () => { + const agent = makeAgent({ + system_instructions_file_path: null, + system_instructions_content: null, + }) + vi.mocked(acpListAgents).mockResolvedValue([agent]) + vi.mocked(listModelProviders).mockResolvedValue([]) + + render( + + + + ) + + await screen.findByRole("heading", { name: "Gemini" }) + expect(screen.queryByText("System Instructions")).not.toBeInTheDocument() + }) + + it("the shared config save leaves untouched instructions alone", async () => { + vi.mocked(acpListAgents).mockResolvedValue([claudeAgent()]) + vi.mocked(listModelProviders).mockResolvedValue([]) + vi.mocked(acpUpdateAgentEnv).mockResolvedValue(0) + vi.mocked(acpUpdateAgentConfig).mockResolvedValue(0) + + renderSettings() + await screen.findByRole("heading", { name: "Claude Code" }) + + fireEvent.click( + screen.getByRole("button", { + name: enMessages.AcpAgentSettings.actions.saveConfigManagement, + }) + ) + + await waitFor(() => expect(acpUpdateAgentConfig).toHaveBeenCalledTimes(1)) + expect(vi.mocked(acpUpdateAgentConfig).mock.calls[0][1]).toMatchObject({ + system_instructions_content: null, + }) + }) + + it("the shared config save sends edited instructions once", async () => { + vi.mocked(acpListAgents).mockResolvedValue([claudeAgent()]) + vi.mocked(listModelProviders).mockResolvedValue([]) + vi.mocked(acpUpdateAgentEnv).mockResolvedValue(0) + vi.mocked(acpUpdateAgentConfig).mockResolvedValue(0) + + renderSettings() + await screen.findByRole("heading", { name: "Claude Code" }) + + fireEvent.change( + screen.getByPlaceholderText( + enMessages.AcpAgentSettings.systemInstructions.placeholder + ), + { target: { value: "# Edited" } } + ) + const save = screen.getByRole("button", { + name: enMessages.AcpAgentSettings.actions.saveConfigManagement, + }) + fireEvent.click(save) + + await waitFor(() => expect(acpUpdateAgentConfig).toHaveBeenCalledTimes(1)) + expect(vi.mocked(acpUpdateAgentConfig).mock.calls[0][1]).toMatchObject({ + system_instructions_content: "# Edited", + system_instructions_file_path: "/home/test/.claude/CLAUDE.md", + }) + + // Saved: the next shared save no longer carries it. + await waitFor(() => expect(save).toBeEnabled()) + fireEvent.click(save) + await waitFor(() => expect(acpUpdateAgentConfig).toHaveBeenCalledTimes(2)) + expect(vi.mocked(acpUpdateAgentConfig).mock.calls[1][1]).toMatchObject({ + system_instructions_content: null, + }) + }) +}) + +describe("rebaseSystemInstructionsDraft", () => { + type Draft = Parameters[0] + const draft = (text: string, baseline: string) => + ({ + systemInstructionsText: text, + systemInstructionsBaseline: baseline, + }) as Draft + + it("follows the file while the editor is untouched", () => { + const next = rebaseSystemInstructionsDraft( + draft("old", "old"), + makeAgent({ system_instructions_content: "new" }) + ) + expect(next.systemInstructionsText).toBe("new") + expect(next.systemInstructionsBaseline).toBe("new") + }) + + it("keeps an unsaved edit, and returns the same draft when nothing moved", () => { + const edited = draft("typed", "old") + expect( + rebaseSystemInstructionsDraft( + edited, + makeAgent({ system_instructions_content: "new" }) + ) + ).toBe(edited) + const clean = draft("same", "same") + expect( + rebaseSystemInstructionsDraft( + clean, + makeAgent({ system_instructions_content: "same" }) + ) + ).toBe(clean) + }) +}) diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index c02ea3bc618..7027d259169 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -237,6 +237,11 @@ type ClaudeAuthMode = (typeof CLAUDE_AUTH_MODES)[number] interface AgentDraft { enabled: boolean envText: string + systemInstructionsText: string + /** The instructions file's content when the draft was built (or last + * saved): the shared save sends the text only when it differs, so an + * untouched draft never writes the agent's file. */ + systemInstructionsBaseline: string configText: string apiBaseUrl: string apiKey: string @@ -515,6 +520,28 @@ function parseEnvText(envText: string): Record { return map } +/** + * Re-read on refresh: the instructions file is often edited outside codeg (by + * hand, or by the agent's own CLI), so an untouched editor shows what is on + * disk now instead of the text it was first loaded with. + */ +export function rebaseSystemInstructionsDraft( + draft: AgentDraft, + agent: AcpAgentInfo +): AgentDraft { + // An unsaved edit is kept as typed; an untouched editor follows the file. + if (draft.systemInstructionsText !== draft.systemInstructionsBaseline) { + return draft + } + const next = agent.system_instructions_content ?? "" + if (next === draft.systemInstructionsBaseline) return draft + return { + ...draft, + systemInstructionsText: next, + systemInstructionsBaseline: next, + } +} + /** * Fold the DeepSeek panel's own env keys, as they are actually persisted, into * an existing draft. Everything else in the draft — other keys, and any @@ -3885,6 +3912,8 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { return { enabled: agent.enabled, envText, + systemInstructionsText: agent.system_instructions_content ?? "", + systemInstructionsBaseline: agent.system_instructions_content ?? "", configText, apiBaseUrl: agent.agent_type === "hermes" @@ -4851,6 +4880,9 @@ export function AcpAgentSettings() { const [savingConfig, setSavingConfig] = useState< Partial> >({}) + const [savingInstructions, setSavingInstructions] = useState< + Partial> + >({}) const [modelProviders, setModelProviders] = useState([]) const [uninstallConfirmAgent, setUninstallConfirmAgent] = useState(null) @@ -5022,6 +5054,13 @@ export function AcpAgentSettings() { if (agent.agent_type === "deepseek" && existing) { updated[agent.agent_type] = rebaseDeepSeekDraft(existing, agent) } + const current = updated[agent.agent_type] + if (current) { + updated[agent.agent_type] = rebaseSystemInstructionsDraft( + current, + agent + ) + } } return updated }) @@ -5342,11 +5381,69 @@ export function AcpAgentSettings() { [reportAffectedSessions] ) + // The file now holds `content`: an editor still showing it is clean again, + // and one the user kept typing in stays dirty against the new baseline. + const markSystemInstructionsSaved = useCallback( + (agentType: AgentType, content: string) => { + setDrafts((prev) => { + const draft = prev[agentType] + if (!draft || draft.systemInstructionsBaseline === content) return prev + return { + ...prev, + [agentType]: { ...draft, systemInstructionsBaseline: content }, + } + }) + }, + [] + ) + + const persistSystemInstructions = useCallback( + async ( + agentType: AgentType, + content: string, + filePath: string | null | undefined + ) => { + setSavingInstructions((prev) => ({ ...prev, [agentType]: true })) + try { + const affected = await acpUpdateAgentConfig(agentType, { + system_instructions_content: content, + system_instructions_file_path: filePath ?? null, + }) + reportAffectedSessions(affected) + setAgents((prev) => + prev.map((agent) => + agent.agent_type === agentType + ? { + ...agent, + system_instructions_content: content, + } + : agent + ) + ) + markSystemInstructionsSaved(agentType, content) + toast.success(t("toasts.systemInstructionsSaved"), { + description: t("toasts.configSavedHint"), + }) + } catch (err) { + console.error("[Settings] save system instructions failed:", err) + const message = toErrorMessage(err) + toast.error(t("toasts.saveSystemInstructionsFailed"), { + description: message, + }) + } finally { + setSavingInstructions((prev) => ({ ...prev, [agentType]: false })) + } + }, + [markSystemInstructionsSaved, reportAffectedSessions, t] + ) + const persistConfig = useCallback( async ( agentType: AgentType, configText: string, options?: { + systemInstructionsText?: string + systemInstructionsFilePath?: string codexAuthJsonText?: string codexConfigTomlText?: string codexConfigTomlBaseline?: string @@ -5398,6 +5495,12 @@ export function AcpAgentSettings() { try { const affected = await acpUpdateAgentConfig(agentType, { config_json: configForPersist || null, + system_instructions_content: + typeof options?.systemInstructionsText === "string" + ? options.systemInstructionsText + : null, + system_instructions_file_path: + options?.systemInstructionsFilePath ?? null, codex_auth_json: typeof codexAuthJsonText === "string" ? codexAuthJsonText : null, codex_config_toml: @@ -5426,6 +5529,10 @@ export function AcpAgentSettings() { ? { ...agent, config_json: normalizedConfig || null, + system_instructions_content: + typeof options?.systemInstructionsText === "string" + ? options.systemInstructionsText + : agent.system_instructions_content, codex_auth_json: typeof codexAuthJsonText === "string" ? codexAuthJsonText @@ -5468,11 +5575,14 @@ export function AcpAgentSettings() { : agent ) ) + if (typeof options?.systemInstructionsText === "string") { + markSystemInstructionsSaved(agentType, options.systemInstructionsText) + } } finally { setSavingConfig((prev) => ({ ...prev, [agentType]: false })) } }, - [agents, reportAffectedSessions] + [agents, markSystemInstructionsSaved, reportAffectedSessions] ) // After a Grok save, re-read the merged on-disk config and rebuild the Grok @@ -6249,6 +6359,9 @@ export function AcpAgentSettings() { const selectedIsSavingConfig = selectedAgent ? Boolean(savingConfig[selectedAgent.agent_type]) : false + const selectedIsSavingInstructions = selectedAgent + ? Boolean(savingInstructions[selectedAgent.agent_type]) + : false const selectedAgentKind = selectedAgent?.agent_type ?? null const selectedModelProviders = useMemo(() => { @@ -8822,6 +8935,64 @@ export function AcpAgentSettings() { + {selectedAgent.system_instructions_file_path && ( +
+
+ + + {selectedAgent.system_instructions_file_path} + +
+

+ {t("systemInstructions.hint")} +

+
+