Skip to main content

locode_instructions/
render.rs

1//! Rendering discovered project instructions into a conversation message (ADR-0023 §2).
2//!
3//! The loader in [`crate::load`] produces a neutral [`ProjectInstructions`]; this turns
4//! it into **one `User`-role `<system-reminder>` message** and injects it (Slice 3). This is
5//! the "engine renders / injects the neutral value" half of ADR-0023 — a single format for
6//! every pack (fidelity is bounded to tools + prompt, ADR-0023 §1).
7//!
8//! Why `User` and not `Developer`: injected framing is authored as `User` from the start so
9//! the conversation ⇄ payload conversion stays losslessly bidirectional (ADR-0013 amendment
10//! 2026-07-23) — a `Developer`-rendered-to-user fallback cannot be reversed.
11
12use std::hash::{Hash, Hasher};
13
14use crate::load::ProjectInstructions;
15use locode_protocol::{ContentBlock, Message, Role};
16
17/// The authority preamble + relevance disclaimer (Claude's framing —
18/// `claude-code: api.ts:461-473`).
19const PREAMBLE: &str = "As you answer the user's questions, you can use the project \
20instructions below (deeper directories take precedence on conflict). They are context, \
21not a message to answer.";
22
23/// Leads the envelope when the instructions changed mid-session (ADR-0023 Refresh, the
24/// Codex/opencode replace-banner pattern — `codex: context/world_state/agents_md.rs:9-11`).
25const REPLACE_BANNER: &str =
26    "These instructions replace all previously provided project instructions.";
27
28/// The whole body when the instructions vanished mid-session.
29const REMOVAL_NOTICE: &str = "The previously provided project instructions no longer apply.";
30
31/// Appended when the body is truncated to the byte budget.
32const TRUNCATION_MARKER: &str = "\n\n…[project instructions truncated]…";
33
34/// Render project instructions into one `User` `<system-reminder>` message, or `None` when
35/// there is nothing to inject.
36///
37/// Each entry becomes a `## From: <source_path>` section (root→cwd order, so the deepest
38/// file is last). The **sections body** is capped at `byte_budget` bytes (UTF-8-boundary
39/// safe) with a truncation marker; `byte_budget == 0` means unbounded. When `replace` is set
40/// (the content changed mid-session), a replace banner leads the envelope.
41#[must_use]
42pub fn render_instructions(
43    instructions: &ProjectInstructions,
44    byte_budget: usize,
45    replace: bool,
46) -> Option<Message> {
47    if instructions.entries.is_empty() {
48        return None;
49    }
50
51    let body = render_body(instructions, byte_budget)?;
52
53    let lead = if replace {
54        format!("{REPLACE_BANNER}\n{PREAMBLE}")
55    } else {
56        PREAMBLE.to_string()
57    };
58    let text = format!("<system-reminder>\n{lead}\n\n{body}\n</system-reminder>");
59    Some(user_reminder(text))
60}
61
62/// The removal banner message, emitted when previously-applied instructions vanish.
63#[must_use]
64pub fn removal_message() -> Message {
65    user_reminder(format!(
66        "<system-reminder>\n{REMOVAL_NOTICE}\n</system-reminder>"
67    ))
68}
69
70/// A stable hash of the discovered instructions, or `None` when nothing was discovered.
71///
72/// Retained for callers that want a cheap "did the files change?" signal. It is **not**
73/// what decides re-injection — see [`already_delivered`], and ADR-0023's Refresh rule
74/// that instructions are "never double-injected on fork/resume".
75#[must_use]
76pub fn instructions_hash(instructions: &ProjectInstructions) -> Option<u64> {
77    if instructions.entries.is_empty() {
78        return None;
79    }
80    let mut hasher = std::collections::hash_map::DefaultHasher::new();
81    for entry in &instructions.entries {
82        entry.source_path.hash(&mut hasher);
83        entry.content.hash(&mut hasher);
84    }
85    Some(hasher.finish())
86}
87
88/// The instructions body, without the envelope or the replace banner — the unit the
89/// delivery check compares.
90#[must_use]
91pub fn render_body(instructions: &ProjectInstructions, byte_budget: usize) -> Option<String> {
92    if instructions.entries.is_empty() {
93        return None;
94    }
95    let sections: Vec<String> = instructions
96        .entries
97        .iter()
98        .map(|entry| {
99            format!(
100                "## From: {}\n{}",
101                entry.source_path.display(),
102                entry.content.trim_end()
103            )
104        })
105        .collect();
106    Some(truncate_on_char_boundary(
107        sections.join("\n\n"),
108        byte_budget,
109    ))
110}
111
112/// Whether the conversation already carries exactly this instructions body.
113///
114/// Read off the **transcript**, not a field on the session — ADR-0023's Refresh rule
115/// requires instructions to be "never double-injected on fork/resume", and a
116/// remembered hash cannot deliver that: a resumed session starts with the field empty
117/// and re-injects instructions the replayed transcript already contains. This is the
118/// same resolution ADR-0025 §3.1 gives skills (codex's `PreviousSectionState`), and it
119/// buys the other half of ADR-0023's rule for free: drop the message in compaction and
120/// the next turn re-injects it.
121#[must_use]
122pub fn already_delivered(history: &[Message], body: &str) -> bool {
123    history.iter().rev().any(|m| {
124        m.role == Role::User
125            && m.content.iter().any(|b| match b {
126                ContentBlock::Text { text } => text.contains(PREAMBLE) && text.contains(body),
127                _ => false,
128            })
129    })
130}
131
132/// Whether the conversation carries any instructions message at all — including a
133/// removal notice.
134///
135/// Tells "never injected" from "injected, then the files vanished", so the removal
136/// notice is sent once and only when there was something to remove.
137#[must_use]
138pub fn any_delivered(history: &[Message]) -> bool {
139    history.iter().any(|m| {
140        m.role == Role::User
141            && m.content.iter().any(|b| match b {
142                ContentBlock::Text { text } => {
143                    text.contains(PREAMBLE) || text.contains(REMOVAL_NOTICE)
144                }
145                _ => false,
146            })
147    })
148}
149
150/// Whether the most recent instructions message in the conversation is a removal
151/// notice — i.e. the "no instructions apply" state has already been announced.
152#[must_use]
153pub fn removal_delivered(history: &[Message]) -> bool {
154    history
155        .iter()
156        .rev()
157        .find_map(|m| {
158            if m.role != Role::User {
159                return None;
160            }
161            m.content.iter().find_map(|b| match b {
162                ContentBlock::Text { text }
163                    if text.contains(PREAMBLE) || text.contains(REMOVAL_NOTICE) =>
164                {
165                    Some(text.as_str())
166                }
167                _ => None,
168            })
169        })
170        .is_some_and(|text| text.contains(REMOVAL_NOTICE))
171}
172
173/// A `User`-role `<system-reminder>` message wrapping `text`.
174fn user_reminder(text: String) -> Message {
175    Message {
176        role: Role::User,
177        content: vec![ContentBlock::Text { text }],
178    }
179}
180
181/// Truncate `s` to at most `budget` bytes at a UTF-8 char boundary, appending a marker when
182/// anything was dropped. `budget == 0` ⇒ unbounded (returned unchanged).
183fn truncate_on_char_boundary(mut s: String, budget: usize) -> String {
184    if budget == 0 || s.len() <= budget {
185        return s;
186    }
187    let mut end = budget;
188    while end > 0 && !s.is_char_boundary(end) {
189        end -= 1;
190    }
191    s.truncate(end);
192    s.push_str(TRUNCATION_MARKER);
193    s
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::load::InstructionEntry;
200    use std::path::PathBuf;
201
202    fn instr(entries: &[(&str, &str)]) -> ProjectInstructions {
203        ProjectInstructions {
204            entries: entries
205                .iter()
206                .map(|(p, c)| InstructionEntry {
207                    source_path: PathBuf::from(p),
208                    content: (*c).to_string(),
209                })
210                .collect(),
211        }
212    }
213
214    #[test]
215    fn renders_exact_envelope() {
216        let msg = render_instructions(&instr(&[("/p1", "a\n"), ("/p2", "b")]), 0, false).unwrap();
217        let ContentBlock::Text { text } = &msg.content[0] else {
218            panic!("expected text block");
219        };
220        let expected = "<system-reminder>\n\
221            As you answer the user's questions, you can use the project instructions below \
222            (deeper directories take precedence on conflict). They are context, not a \
223            message to answer.\n\
224            \n\
225            ## From: /p1\n\
226            a\n\
227            \n\
228            ## From: /p2\n\
229            b\n\
230            </system-reminder>";
231        assert_eq!(text, expected);
232    }
233
234    #[test]
235    fn empty_renders_none() {
236        assert!(render_instructions(&instr(&[]), 0, false).is_none());
237    }
238
239    #[test]
240    fn injected_message_is_user_text() {
241        let msg = render_instructions(&instr(&[("/p", "x")]), 0, false).unwrap();
242        assert_eq!(msg.role, Role::User);
243        assert_eq!(msg.content.len(), 1);
244        assert!(matches!(msg.content[0], ContentBlock::Text { .. }));
245    }
246
247    #[test]
248    fn truncates_at_budget_on_char_boundary_with_marker() {
249        // A body well over a tiny budget, containing multi-byte chars near the cut.
250        let big = "é".repeat(200); // 400 bytes
251        let msg = render_instructions(&instr(&[("/p", &big)]), 64, false).unwrap();
252        let ContentBlock::Text { text } = &msg.content[0] else {
253            panic!();
254        };
255        assert!(
256            text.contains("…[project instructions truncated]…"),
257            "marker present"
258        );
259        // The section body was capped at 64 bytes before the marker; the envelope adds the
260        // preamble + tags, but the point is the body did not blow past the budget.
261        let body_start = text.find("## From:").unwrap();
262        let body_end = text.find(TRUNCATION_MARKER).unwrap();
263        assert!(body_end - body_start <= 64, "body ≤ budget before marker");
264        // The cut landed on a char boundary — else `String::truncate` in the renderer
265        // would have panicked before we got here, splitting an `é` mid-sequence.
266    }
267
268    #[test]
269    fn zero_budget_is_unbounded() {
270        let big = "x".repeat(10_000);
271        let msg = render_instructions(&instr(&[("/p", &big)]), 0, false).unwrap();
272        let ContentBlock::Text { text } = &msg.content[0] else {
273            panic!();
274        };
275        assert!(!text.contains("truncated"));
276        assert!(text.len() > 10_000);
277    }
278
279    #[test]
280    fn replace_flag_leads_with_banner() {
281        let msg = render_instructions(&instr(&[("/p", "x")]), 0, true).unwrap();
282        let ContentBlock::Text { text } = &msg.content[0] else {
283            panic!();
284        };
285        assert!(
286            text.starts_with(
287                "<system-reminder>\nThese instructions replace all previously provided \
288                 project instructions.\n"
289            ),
290            "{text}"
291        );
292        assert!(text.contains("## From: /p"));
293    }
294
295    #[test]
296    fn removal_message_is_user_notice() {
297        let msg = removal_message();
298        assert_eq!(msg.role, Role::User);
299        let ContentBlock::Text { text } = &msg.content[0] else {
300            panic!();
301        };
302        assert_eq!(
303            text,
304            "<system-reminder>\nThe previously provided project instructions no longer \
305             apply.\n</system-reminder>"
306        );
307    }
308
309    #[test]
310    fn hash_is_none_empty_stable_and_content_sensitive() {
311        assert_eq!(instructions_hash(&instr(&[])), None);
312        let a = instructions_hash(&instr(&[("/p", "x")]));
313        let a2 = instructions_hash(&instr(&[("/p", "x")]));
314        let b = instructions_hash(&instr(&[("/p", "y")]));
315        assert!(a.is_some());
316        assert_eq!(a, a2, "stable for identical content");
317        assert_ne!(a, b, "changes when content changes");
318    }
319}