Skip to content

Commit a5c4a21

Browse files
linzhiqin2003claude
authored andcommitted
feat(project-context): merge global AGENTS.md with project AGENTS.md (#1157)
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 (`<!-- global: /home/u/.deepseek/AGENTS.md -->`), then the project block follows with its own fence (`<!-- project (overrides global where they conflict) -->`). 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) <noreply@anthropic.com>
1 parent 29a42ba commit a5c4a21

1 file changed

Lines changed: 97 additions & 9 deletions

File tree

crates/tui/src/project_context.rs

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -381,13 +381,32 @@ fn load_project_context_with_parents_and_home(
381381
}
382382
}
383383

384-
if !ctx.has_instructions()
385-
&& let Some(global_ctx) = load_global_agents_context(workspace, home_dir)
386-
{
384+
// Always check `~/.deepseek/AGENTS.md` so user-wide preferences
385+
// travel into every session (#1157). When both global and project
386+
// instructions exist, the global block prepends the project's so
387+
// workspace overrides win the last word; when only global exists,
388+
// it continues to serve as the fallback. `source_path` keeps
389+
// pointing at the more-specific source (project > global) for
390+
// display purposes.
391+
if let Some(global_ctx) = load_global_agents_context(workspace, home_dir) {
387392
ctx.warnings.extend(global_ctx.warnings.iter().cloned());
388-
if global_ctx.has_instructions() {
389-
ctx.instructions = global_ctx.instructions;
390-
ctx.source_path = global_ctx.source_path;
393+
if let Some(global_text) = global_ctx.instructions {
394+
match ctx.instructions.take() {
395+
Some(project_text) => {
396+
ctx.instructions = Some(merge_global_and_project_instructions(
397+
&global_text,
398+
global_ctx.source_path.as_deref(),
399+
&project_text,
400+
));
401+
// Leave `ctx.source_path` pointing at the project /
402+
// parent file — that's the location the user might
403+
// want to edit when something looks wrong.
404+
}
405+
None => {
406+
ctx.instructions = Some(global_text);
407+
ctx.source_path = global_ctx.source_path;
408+
}
409+
}
391410
}
392411
}
393412

@@ -412,6 +431,27 @@ fn load_project_context_with_parents_and_home(
412431
ctx
413432
}
414433

434+
/// Combine `~/.deepseek/AGENTS.md` (global, user-wide preferences) with a
435+
/// project-local AGENTS.md/CLAUDE.md/instructions.md. Global comes first
436+
/// so workspace-specific rules can override it — the model reads in
437+
/// declared order. Each block is wrapped in a labelled fence so the
438+
/// model can tell which level any rule comes from when the two sets
439+
/// disagree (#1157).
440+
fn merge_global_and_project_instructions(
441+
global: &str,
442+
global_source: Option<&Path>,
443+
project: &str,
444+
) -> String {
445+
let global_label = global_source
446+
.map(|p| format!("<!-- global: {} -->", p.display()))
447+
.unwrap_or_else(|| "<!-- global -->".to_string());
448+
format!(
449+
"{global_label}\n{}\n\n<!-- project (overrides global where they conflict) -->\n{}",
450+
global.trim_end(),
451+
project.trim_start(),
452+
)
453+
}
454+
415455
fn load_global_agents_context(workspace: &Path, home_dir: Option<&Path>) -> Option<ProjectContext> {
416456
let home = home_dir?;
417457
let mut path = home.to_path_buf();
@@ -847,7 +887,10 @@ mod tests {
847887
}
848888

849889
#[test]
850-
fn test_local_agents_takes_priority_over_global_agents() {
890+
fn test_local_and_global_agents_merge_when_both_exist() {
891+
// #1157: when both `~/.deepseek/AGENTS.md` and a project AGENTS.md
892+
// exist, the prompt should carry user-wide preferences AND the
893+
// project's overrides — not silently drop the global file.
851894
let workspace = tempdir().expect("workspace tempdir");
852895
fs::write(workspace.path().join("AGENTS.md"), "Local instructions")
853896
.expect("write local agents");
@@ -862,11 +905,56 @@ mod tests {
862905

863906
assert!(ctx.has_instructions());
864907
let instructions = ctx.instructions.as_ref().unwrap();
865-
assert!(instructions.contains("Local instructions"));
866-
assert!(!instructions.contains("Global instructions"));
908+
assert!(
909+
instructions.contains("Global instructions"),
910+
"global block missing from merged instructions:\n{instructions}"
911+
);
912+
assert!(
913+
instructions.contains("Local instructions"),
914+
"project block missing from merged instructions:\n{instructions}"
915+
);
916+
// Global block precedes the project block so project rules read
917+
// last and win "last word" precedence with the model.
918+
let global_at = instructions.find("Global instructions").unwrap();
919+
let local_at = instructions.find("Local instructions").unwrap();
920+
assert!(
921+
global_at < local_at,
922+
"global block must come before project block, got global={global_at} local={local_at}"
923+
);
924+
// The merged block is labelled so the model can tell the layers
925+
// apart when it needs to explain which rule it followed.
926+
assert!(
927+
instructions.contains("project (overrides global where they conflict)"),
928+
"expected labelled separator between global and project blocks"
929+
);
930+
// `source_path` keeps pointing at the more-specific file so the
931+
// user knows where to edit the workspace-level override.
867932
assert_eq!(ctx.source_path, Some(workspace.path().join("AGENTS.md")));
868933
}
869934

935+
#[test]
936+
fn test_global_agents_only_no_project_unchanged_fallback() {
937+
// Sanity: when only the global file exists, the historical
938+
// fallback behaviour is preserved — no merge framing leaks in.
939+
let workspace = tempdir().expect("workspace tempdir");
940+
let home = tempdir().expect("home tempdir");
941+
let global_dir = home.path().join(".deepseek");
942+
fs::create_dir(&global_dir).expect("mkdir .deepseek");
943+
let global_agents = global_dir.join("AGENTS.md");
944+
fs::write(&global_agents, "Just the global instructions").expect("write global agents");
945+
946+
let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
947+
948+
assert!(ctx.has_instructions());
949+
let instructions = ctx.instructions.as_ref().unwrap();
950+
assert!(instructions.contains("Just the global instructions"));
951+
assert!(
952+
!instructions.contains("project (overrides global"),
953+
"merge-framing label should not appear when there's nothing to merge"
954+
);
955+
assert_eq!(ctx.source_path, Some(global_agents));
956+
}
957+
870958
#[test]
871959
fn test_invalid_global_agents_warns_and_falls_back_to_generated_context() {
872960
let workspace = tempdir().expect("workspace tempdir");

0 commit comments

Comments
 (0)