From 0602054a649aa96ce20756f87c17c5f019372fde Mon Sep 17 00:00:00 2001 From: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:11:51 +0800 Subject: [PATCH 1/4] fix(context): prompt instructions source as file name The label carried the absolute path of the loaded AGENTS.md. The label sits inside the pinned system prompt, so loading an unchanged file from a moved or recased directory rewrote the label and emitted a spurious history append, and it leaked the absolute project path into a provider-bound prompt label. The label now reports the file name only. Directory identity is discoverable at runtime via the shell; the label is an origin tag, not a locator. A regression test pins byte-identity of the block for identical content loaded from two different directories. Signed-off-by: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com> --- crates/tui/src/context_report.rs | 6 +++++- crates/tui/src/project_context.rs | 20 ++++++++++++++++++++ crates/tui/src/project_context/types.rs | 10 +++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/context_report.rs b/crates/tui/src/context_report.rs index c21ec1a03c..58016a8d56 100644 --- a/crates/tui/src/context_report.rs +++ b/crates/tui/src/context_report.rs @@ -449,10 +449,14 @@ fn base_source_entries( } if let Some(content) = project_context.instructions.as_deref() { + // 与 ProjectContext::as_system_block 同语义:source 只报文件名,报告与 + // 提示词看到的标签一致,不泄漏绝对路径。 let source = project_context .source_path .as_ref() - .map_or_else(|| "project".to_string(), |p| p.display().to_string()); + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "project".to_string()); let mut block = format!( "\n{content}\n" ); diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 5aafb59944..4dfb5b2890 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -1395,6 +1395,26 @@ mod tests { assert!(block.contains("")); } + + #[test] + fn test_as_system_block_source_is_file_name_not_absolute_path() { + // 提示词前缀含 source 标签且位于 KV 缓存稳定区(块 2):同一份 + // AGENTS.md 被搬到不同目录后重载,块文本必须逐字节一致,否则整段 + // 请求(含历史)的提供商前缀缓存全损。 + let dir_a = tempdir().expect("tempdir a"); + let dir_b = tempdir().expect("tempdir b"); + fs::write(dir_a.path().join("AGENTS.md"), "Pinned content").expect("write a"); + fs::write(dir_b.path().join("AGENTS.md"), "Pinned content").expect("write b"); + + let block_a = load_project_context(dir_a.path()).as_system_block().expect("block a"); + let block_b = load_project_context(dir_b.path()).as_system_block().expect("block b"); + assert_eq!(block_a, block_b, "目录移动不应改变项目指令块"); + assert!(block_a.contains("source=\"AGENTS.md\"")); + assert!( + !block_a.contains(&dir_a.path().display().to_string()), + "绝对路径不得进入提示词标签" + ); + } #[test] fn test_empty_file_warning() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/project_context/types.rs b/crates/tui/src/project_context/types.rs index d12d219ccf..6cab17cf96 100644 --- a/crates/tui/src/project_context/types.rs +++ b/crates/tui/src/project_context/types.rs @@ -87,10 +87,18 @@ impl ProjectContext { /// cross-agent `` prose. Either may be absent. pub fn as_system_block(&self) -> Option { let instructions_block = self.instructions.as_ref().map(|content| { + // Prompt the source by file name only: the absolute path sits in + // the cache-stable prefix (block 2 of the system prompt), so an + // unchanged file whose directory moved or was cased differently + // would bust the provider's KV prefix cache for the entire + // request. Directory identity is still discoverable via the shell + // runtime; the `source` attribute is an origin label, not a locator. let source = self .source_path .as_ref() - .map_or_else(|| "project".to_string(), |p| p.display().to_string()); + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "project".to_string()); let mut block = format!( "\n{content}\n" From 8e5d4b0b7074251abc25888b3e57e003cdd626db Mon Sep 17 00:00:00 2001 From: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:43:02 +0800 Subject: [PATCH 2/4] refactor(context): share the project_instructions source label helper v0.9.12-line adaptation: the helper lands in the split-out project_context/types.rs and is re-exported through project_context. This line's compaction was fully restructured, so the verbatim AGENTS.md reinjection (project_instructions_section) no longer exists and the old-line forkguard_compaction_reinject_* tests have no injection point to port; the label in merge_global_and_project_instructions is deliberately untouched because the home path does not drift with the workspace. - Extract the shared project_instructions_source_label helper (file name, fallback "project"); as_system_block and the context report's base_source_entries now share it instead of keeping one file-name logic copy each. - Rename the source-relativization regression test with the forkguard_ prefix. Signed-off-by: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com> --- crates/tui/src/context_report.rs | 13 +++++------ crates/tui/src/project_context.rs | 12 ++++++---- crates/tui/src/project_context/types.rs | 29 ++++++++++++++----------- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/crates/tui/src/context_report.rs b/crates/tui/src/context_report.rs index 58016a8d56..158bfb1d21 100644 --- a/crates/tui/src/context_report.rs +++ b/crates/tui/src/context_report.rs @@ -449,14 +449,11 @@ fn base_source_entries( } if let Some(content) = project_context.instructions.as_deref() { - // 与 ProjectContext::as_system_block 同语义:source 只报文件名,报告与 - // 提示词看到的标签一致,不泄漏绝对路径。 - let source = project_context - .source_path - .as_ref() - .and_then(|path| path.file_name()) - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| "project".to_string()); + // 与 ProjectContext::as_system_block 同一 helper:source 只报文件名, + // 报告与提示词看到的标签一致,不泄漏绝对路径。 + let source = crate::project_context::project_instructions_source_label( + project_context.source_path.as_deref(), + ); let mut block = format!( "\n{content}\n" ); diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 4dfb5b2890..40ecbee50f 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -28,6 +28,7 @@ use self::pack::generate_bounded_project_overview; pub use self::pack::generate_project_context_pack; pub use self::types::ProjectContext; use self::types::ProjectContextError; +pub(crate) use self::types::project_instructions_source_label; /// Names of project context files to look for, in priority order. /// @@ -1395,9 +1396,8 @@ mod tests { assert!(block.contains("")); } - #[test] - fn test_as_system_block_source_is_file_name_not_absolute_path() { + fn forkguard_project_instructions_source_is_file_name_not_absolute_path() { // 提示词前缀含 source 标签且位于 KV 缓存稳定区(块 2):同一份 // AGENTS.md 被搬到不同目录后重载,块文本必须逐字节一致,否则整段 // 请求(含历史)的提供商前缀缓存全损。 @@ -1406,8 +1406,12 @@ mod tests { fs::write(dir_a.path().join("AGENTS.md"), "Pinned content").expect("write a"); fs::write(dir_b.path().join("AGENTS.md"), "Pinned content").expect("write b"); - let block_a = load_project_context(dir_a.path()).as_system_block().expect("block a"); - let block_b = load_project_context(dir_b.path()).as_system_block().expect("block b"); + let block_a = load_project_context(dir_a.path()) + .as_system_block() + .expect("block a"); + let block_b = load_project_context(dir_b.path()) + .as_system_block() + .expect("block b"); assert_eq!(block_a, block_b, "目录移动不应改变项目指令块"); assert!(block_a.contains("source=\"AGENTS.md\"")); assert!( diff --git a/crates/tui/src/project_context/types.rs b/crates/tui/src/project_context/types.rs index 6cab17cf96..2ef17511a5 100644 --- a/crates/tui/src/project_context/types.rs +++ b/crates/tui/src/project_context/types.rs @@ -2,7 +2,7 @@ //! `ProjectContext` value that carries loaded instructions, rules, and the //! rendered repo-constitution block into the system prompt. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use thiserror::Error; @@ -87,18 +87,7 @@ impl ProjectContext { /// cross-agent `` prose. Either may be absent. pub fn as_system_block(&self) -> Option { let instructions_block = self.instructions.as_ref().map(|content| { - // Prompt the source by file name only: the absolute path sits in - // the cache-stable prefix (block 2 of the system prompt), so an - // unchanged file whose directory moved or was cased differently - // would bust the provider's KV prefix cache for the entire - // request. Directory identity is still discoverable via the shell - // runtime; the `source` attribute is an origin label, not a locator. - let source = self - .source_path - .as_ref() - .and_then(|path| path.file_name()) - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| "project".to_string()); + let source = project_instructions_source_label(self.source_path.as_deref()); let mut block = format!( "\n{content}\n" @@ -134,6 +123,20 @@ impl ProjectContext { } } +/// The `source` label for `` blocks: the context +/// file's name only, never its absolute path. The label sits in the +/// cache-stable prefix of the system prompt (block 2), so an unchanged file +/// whose directory moved or was recased must not rewrite it — that would +/// bust the provider KV prefix cache for the entire request, history +/// included. Directory identity stays discoverable via the shell; the label +/// names the origin, it is not a locator. +pub(crate) fn project_instructions_source_label(source_path: Option<&Path>) -> String { + source_path + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "project".to_string()) +} + /// Merge multiple project contexts (e.g., from nested directories) #[allow(dead_code)] // Public API for monorepo context merging pub fn merge_contexts(contexts: &[ProjectContext]) -> Option { From b97bfbcc88a0d5c9bbc9cdd2d227b6998d2b7bab Mon Sep 17 00:00:00 2001 From: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:02:08 +0800 Subject: [PATCH 3/4] fix(context): apply the file-name source label to the constitution block Review round 1 on the split PR: the constitution block sits in the same pinned system-prompt region as and still carried an absolute source path, while its loader always resolves the fixed relative path (".codewhale/constitution.json"), so the shared helper applies with no new decision. The locator stays available via constitution_source_path in the report and /constitution. Also aligns the story with what this line actually does: the pinned system prompt is never rewritten (workspace drift reaches the model as a bounded history append), so the honest benefits of the file-name label are (1) no spurious context_update append after a directory move or recase, (2) absolute project paths stay out of provider-bound prompt labels, and (3) one shared helper for both label renderings. Ancestor-chain and project-rule labels keep their absolute spellings for now; relativizing those needs a repo-relative decision and is tracked in the parent repo (Pinvou/pinvou-agent#514). Also includes: a unit test for the helper's "project" fallback, the test comment and inline comment translations to match the base language, and an assert message scoped to what the regression test actually pins. Signed-off-by: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com> --- crates/tui/src/context_report.rs | 6 ++- crates/tui/src/project_context.rs | 40 ++++++++++++++++--- .../tui/src/project_context/constitution.rs | 8 +++- crates/tui/src/project_context/types.rs | 15 +++---- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/crates/tui/src/context_report.rs b/crates/tui/src/context_report.rs index 158bfb1d21..afa92bb826 100644 --- a/crates/tui/src/context_report.rs +++ b/crates/tui/src/context_report.rs @@ -449,8 +449,10 @@ fn base_source_entries( } if let Some(content) = project_context.instructions.as_deref() { - // 与 ProjectContext::as_system_block 同一 helper:source 只报文件名, - // 报告与提示词看到的标签一致,不泄漏绝对路径。 + // Same helper as ProjectContext::as_system_block, so the report's + // source token derives from the same label the prompt shows. (The + // entry below still displays the absolute source path for operators; + // only the prompt label is relativized.) let source = crate::project_context::project_instructions_source_label( project_context.source_path.as_deref(), ); diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 40ecbee50f..8dec398656 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -1396,11 +1396,29 @@ mod tests { assert!(block.contains("")); } + #[test] + fn project_instructions_source_label_falls_back_to_project() { + use crate::project_context::project_instructions_source_label; + assert_eq!( + project_instructions_source_label(None), + "project", + "a missing source path keeps the historical literal label" + ); + assert_eq!( + project_instructions_source_label(Some(std::path::Path::new("/etc/AGENTS.md"))), + "AGENTS.md" + ); + } + #[test] fn forkguard_project_instructions_source_is_file_name_not_absolute_path() { - // 提示词前缀含 source 标签且位于 KV 缓存稳定区(块 2):同一份 - // AGENTS.md 被搬到不同目录后重载,块文本必须逐字节一致,否则整段 - // 请求(含历史)的提供商前缀缓存全损。 + // The source label sits inside the pinned system prompt: loading the + // same AGENTS.md from a different directory must leave the + // instructions block byte-identical, so a move emits no spurious + // `` history append and no absolute path enters a + // provider-bound label. Scope note: ancestor-chain and project-rule + // labels keep their own (absolute) spellings; relativizing those is + // a separate decision. let dir_a = tempdir().expect("tempdir a"); let dir_b = tempdir().expect("tempdir b"); fs::write(dir_a.path().join("AGENTS.md"), "Pinned content").expect("write a"); @@ -1412,13 +1430,17 @@ mod tests { let block_b = load_project_context(dir_b.path()) .as_system_block() .expect("block b"); - assert_eq!(block_a, block_b, "目录移动不应改变项目指令块"); + assert_eq!( + block_a, block_b, + "a directory move must not change the project instructions block" + ); assert!(block_a.contains("source=\"AGENTS.md\"")); assert!( !block_a.contains(&dir_a.path().display().to_string()), - "绝对路径不得进入提示词标签" + "absolute paths must not enter prompt source labels" ); } + #[test] fn test_empty_file_warning() { let tmp = tempdir().expect("tempdir"); @@ -1685,6 +1707,14 @@ mod tests { .as_deref() .expect("constitution block rendered"); assert!(block.contains("\nCodewhale-specific repo authority policy (local law: subordinate to the global Constitution and the current user request, but above memory and old handoffs; WHALE.md is ignored and should be migrated, not treated as law).\n\n{}", - source.display(), + // Same origin-label convention as ``: file + // name only (the loader's relative path is a compile-time + // constant, so the base name is stable), keeping absolute paths + // out of provider-bound prompt labels. Operators still get the + // locator via `constitution_source_path` in the report and + // `/constitution`. + super::project_instructions_source_label(Some(source)), body.trim_end() ) } diff --git a/crates/tui/src/project_context/types.rs b/crates/tui/src/project_context/types.rs index 2ef17511a5..3adc4a0455 100644 --- a/crates/tui/src/project_context/types.rs +++ b/crates/tui/src/project_context/types.rs @@ -123,13 +123,14 @@ impl ProjectContext { } } -/// The `source` label for `` blocks: the context -/// file's name only, never its absolute path. The label sits in the -/// cache-stable prefix of the system prompt (block 2), so an unchanged file -/// whose directory moved or was recased must not rewrite it — that would -/// bust the provider KV prefix cache for the entire request, history -/// included. Directory identity stays discoverable via the shell; the label -/// names the origin, it is not a locator. +/// The `source` label for `` (and repo constitution) +/// blocks: the context file's name only, never its absolute path. The label +/// sits inside the pinned system prompt, so keeping it stable across +/// directory moves and recasings means an unchanged file does not emit a +/// spurious `` history append after a move, and absolute +/// project paths stay out of provider-bound prompt labels. Directory +/// identity stays discoverable via the shell; the label names the origin, +/// it is not a locator. pub(crate) fn project_instructions_source_label(source_path: Option<&Path>) -> String { source_path .and_then(|path| path.file_name()) From 30ada031e1cba17553fb26b4c2141ebdc447eaee Mon Sep 17 00:00:00 2001 From: asto18089 Date: Wed, 16 Sep 2026 15:22:37 +0800 Subject: [PATCH 4/4] docs(context): correct the constitution source comment The rendered source is a runtime-canonicalized absolute path; only its final segment (REPO_CONSTITUTION_RELATIVE_PATH) is a compile-time constant. The basename-stability claim holds, but the earlier wording implied the whole path was constant. Signed-off-by: asto18089 --- crates/tui/src/project_context/constitution.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/project_context/constitution.rs b/crates/tui/src/project_context/constitution.rs index afaa35c638..3649ad786d 100644 --- a/crates/tui/src/project_context/constitution.rs +++ b/crates/tui/src/project_context/constitution.rs @@ -247,11 +247,13 @@ impl RepoConstitution { format!( "\nCodewhale-specific repo authority policy (local law: subordinate to the global Constitution and the current user request, but above memory and old handoffs; WHALE.md is ignored and should be migrated, not treated as law).\n\n{}", // Same origin-label convention as ``: file - // name only (the loader's relative path is a compile-time - // constant, so the base name is stable), keeping absolute paths - // out of provider-bound prompt labels. Operators still get the - // locator via `constitution_source_path` in the report and - // `/constitution`. + // name only. The rendered `source` here is a runtime-canonicalized + // absolute path (workspace-relative traversal from `REPO_CONSTITUTION_RELATIVE_PATH`), + // but its final segment is that compile-time constant, so the + // base name is stable across directory moves and recasings. This + // keeps absolute paths out of provider-bound prompt labels. + // Operators still get the locator via `constitution_source_path` + // in the report and `/constitution`. super::project_instructions_source_label(Some(source)), body.trim_end() )