From a5c4a21c9ba1ebcb33ab9c65a909eaf4230ea320 Mon Sep 17 00:00:00 2001 From: LinQ Date: Mon, 11 May 2026 00:28:53 +0100 Subject: [PATCH] feat(project-context): merge global AGENTS.md with project AGENTS.md (#1157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit travel into every session, ideally merged with a project's local AGENTS.md when both exist. Maintainer agreed: > yes that makes sense! am working on getting this organizational > structure better today so that worktrees etc can feel like an > intended way of using this. The fallback path already loaded the global file when no workspace context existed, but dropped it silently the moment a project AGENTS.md showed up. After this PR: * Both files present → merged. The global block is prepended with a labelled HTML-style fence (``), then the project block follows with its own fence (``). Order is global-first so workspace rules read last and win "last word" precedence with the model when they disagree. * Only project file present → unchanged from before. * Only global file present → unchanged from before (still acts as a fallback). The merge framing is suppressed in the global-only case so the prompt stays minimal. `source_path` continues to point at the more-specific file (project > global > nothing) because that's the path the user is likely to edit when they want to override something. Two tests: * `test_local_and_global_agents_merge_when_both_exist` — the actual #1157 scenario. Asserts both blocks are present, global precedes project, and the merge-framing label appears between them. * `test_global_agents_only_no_project_unchanged_fallback` — sanity check that the global-only path doesn't accidentally inherit the merge framing. The pre-existing `test_load_global_agents_when_project_has_no_context` still passes, so the global-as-fallback contract is preserved. Refs #1157 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/project_context.rs | 106 +++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index f5a3d800..acde4fbf 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -381,13 +381,32 @@ fn load_project_context_with_parents_and_home( } } - if !ctx.has_instructions() - && let Some(global_ctx) = load_global_agents_context(workspace, home_dir) - { + // Always check `~/.deepseek/AGENTS.md` so user-wide preferences + // travel into every session (#1157). When both global and project + // instructions exist, the global block prepends the project's so + // workspace overrides win the last word; when only global exists, + // it continues to serve as the fallback. `source_path` keeps + // pointing at the more-specific source (project > global) for + // display purposes. + if let Some(global_ctx) = load_global_agents_context(workspace, home_dir) { ctx.warnings.extend(global_ctx.warnings.iter().cloned()); - if global_ctx.has_instructions() { - ctx.instructions = global_ctx.instructions; - ctx.source_path = global_ctx.source_path; + if let Some(global_text) = global_ctx.instructions { + match ctx.instructions.take() { + Some(project_text) => { + ctx.instructions = Some(merge_global_and_project_instructions( + &global_text, + global_ctx.source_path.as_deref(), + &project_text, + )); + // Leave `ctx.source_path` pointing at the project / + // parent file — that's the location the user might + // want to edit when something looks wrong. + } + None => { + ctx.instructions = Some(global_text); + ctx.source_path = global_ctx.source_path; + } + } } } @@ -412,6 +431,27 @@ fn load_project_context_with_parents_and_home( ctx } +/// Combine `~/.deepseek/AGENTS.md` (global, user-wide preferences) with a +/// project-local AGENTS.md/CLAUDE.md/instructions.md. Global comes first +/// so workspace-specific rules can override it — the model reads in +/// declared order. Each block is wrapped in a labelled fence so the +/// model can tell which level any rule comes from when the two sets +/// disagree (#1157). +fn merge_global_and_project_instructions( + global: &str, + global_source: Option<&Path>, + project: &str, +) -> String { + let global_label = global_source + .map(|p| format!("", p.display())) + .unwrap_or_else(|| "".to_string()); + format!( + "{global_label}\n{}\n\n\n{}", + global.trim_end(), + project.trim_start(), + ) +} + fn load_global_agents_context(workspace: &Path, home_dir: Option<&Path>) -> Option { let home = home_dir?; let mut path = home.to_path_buf(); @@ -847,7 +887,10 @@ mod tests { } #[test] - fn test_local_agents_takes_priority_over_global_agents() { + fn test_local_and_global_agents_merge_when_both_exist() { + // #1157: when both `~/.deepseek/AGENTS.md` and a project AGENTS.md + // exist, the prompt should carry user-wide preferences AND the + // project's overrides — not silently drop the global file. let workspace = tempdir().expect("workspace tempdir"); fs::write(workspace.path().join("AGENTS.md"), "Local instructions") .expect("write local agents"); @@ -862,11 +905,56 @@ mod tests { assert!(ctx.has_instructions()); let instructions = ctx.instructions.as_ref().unwrap(); - assert!(instructions.contains("Local instructions")); - assert!(!instructions.contains("Global instructions")); + assert!( + instructions.contains("Global instructions"), + "global block missing from merged instructions:\n{instructions}" + ); + assert!( + instructions.contains("Local instructions"), + "project block missing from merged instructions:\n{instructions}" + ); + // Global block precedes the project block so project rules read + // last and win "last word" precedence with the model. + let global_at = instructions.find("Global instructions").unwrap(); + let local_at = instructions.find("Local instructions").unwrap(); + assert!( + global_at < local_at, + "global block must come before project block, got global={global_at} local={local_at}" + ); + // The merged block is labelled so the model can tell the layers + // apart when it needs to explain which rule it followed. + assert!( + instructions.contains("project (overrides global where they conflict)"), + "expected labelled separator between global and project blocks" + ); + // `source_path` keeps pointing at the more-specific file so the + // user knows where to edit the workspace-level override. assert_eq!(ctx.source_path, Some(workspace.path().join("AGENTS.md"))); } + #[test] + fn test_global_agents_only_no_project_unchanged_fallback() { + // Sanity: when only the global file exists, the historical + // fallback behaviour is preserved — no merge framing leaks in. + let workspace = tempdir().expect("workspace tempdir"); + let home = tempdir().expect("home tempdir"); + let global_dir = home.path().join(".deepseek"); + fs::create_dir(&global_dir).expect("mkdir .deepseek"); + let global_agents = global_dir.join("AGENTS.md"); + fs::write(&global_agents, "Just the global instructions").expect("write global agents"); + + let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path())); + + assert!(ctx.has_instructions()); + let instructions = ctx.instructions.as_ref().unwrap(); + assert!(instructions.contains("Just the global instructions")); + assert!( + !instructions.contains("project (overrides global"), + "merge-framing label should not appear when there's nothing to merge" + ); + assert_eq!(ctx.source_path, Some(global_agents)); + } + #[test] fn test_invalid_global_agents_warns_and_falls_back_to_generated_context() { let workspace = tempdir().expect("workspace tempdir");