Skip to main content

eval_magic/adapters/
claude_code_session.rs

1//! Claude Code-specific rendering of session-start context.
2//!
3//! The available-skills reminder is
4//! a *harness-specific* surface: Claude Code presents discoverable skills to an
5//! agent as "The following skills are available for use with the Skill tool:"
6//! followed by `- name: description` bullets. Plan-mode context is injected as a
7//! `<system-reminder>` block. Both live in an adapter rather than the harness-
8//! agnostic orchestrator so a new harness adds its own renderer alongside.
9
10use std::path::{Path, PathBuf};
11
12use crate::core::AvailableSkill;
13
14/// Render the list of discoverable skills the way a real Claude Code session
15/// surfaces them, so an eval dispatch mirrors a genuine session rather than
16/// announcing itself as an eval. Returns an empty string when no skills are
17/// staged (the caller omits the block entirely in that case).
18pub fn render_available_skills_block(skills: &[AvailableSkill]) -> String {
19    if skills.is_empty() {
20        return String::new();
21    }
22    let mut sorted: Vec<&AvailableSkill> = skills.iter().collect();
23    sorted.sort_by(|a, b| a.name.cmp(&b.name));
24    let mut out = String::from("The following skills are available for use with the Skill tool:\n");
25    for s in sorted {
26        out.push_str(&format!("\n- {}: {}", s.name, s.description));
27    }
28    out
29}
30
31/// Render a plan-mode profile the way Claude Code injects an operating mode into
32/// a live session: as a `<system-reminder>` block the agent is told it is
33/// operating under, not prose it merely reads. Returns an empty string for empty
34/// input so the caller can omit the section entirely.
35pub fn render_plan_mode_context(profile_text: &str) -> String {
36    let trimmed = profile_text.trim();
37    if trimmed.is_empty() {
38        return String::new();
39    }
40    format!("<system-reminder>\n{trimmed}\n</system-reminder>")
41}
42
43/// Slugify an absolute path the way Claude Code names its project directories:
44/// every non-alphanumeric character becomes `-`. For example
45/// `/Users/x/.config/oc` → `-Users-x--config-oc` (the `/` before `.config` and
46/// the `.` each map to a `-`, producing the double hyphen).
47pub fn slugify_project_path(path: &Path) -> String {
48    path.to_string_lossy()
49        .chars()
50        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
51        .collect()
52}
53
54/// Locate the subagents transcript dir for a Claude Code session.
55///
56/// Returns `<config_dir>/projects/<slug>/<session_id>/subagents/` when it
57/// exists, where `<slug>` is [`slugify_project_path`] of `cwd`. If the
58/// cwd-derived slug doesn't match (e.g. the command ran from a subdirectory of
59/// the session's project), scans `<config_dir>/projects/*` for a child named
60/// `<session_id>` — the session id is a globally-unique UUID, so at most one
61/// project dir contains it. Returns `None` if no `subagents/` dir is found.
62pub fn resolve_subagents_dir_for_session(
63    config_dir: &Path,
64    cwd: &Path,
65    session_id: &str,
66) -> Option<PathBuf> {
67    let projects = config_dir.join("projects");
68    let primary = projects
69        .join(slugify_project_path(cwd))
70        .join(session_id)
71        .join("subagents");
72    if primary.is_dir() {
73        return Some(primary);
74    }
75    let entries = std::fs::read_dir(&projects).ok()?;
76    for entry in entries.flatten() {
77        let candidate = entry.path().join(session_id).join("subagents");
78        if candidate.is_dir() {
79            return Some(candidate);
80        }
81    }
82    None
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::core::AvailableSkill;
89    use std::fs;
90    use tempfile::TempDir;
91
92    fn skill(name: &str, description: &str) -> AvailableSkill {
93        AvailableSkill {
94            name: name.into(),
95            path: format!("/x/{name}/SKILL.md"),
96            description: description.into(),
97        }
98    }
99
100    #[test]
101    fn uses_harness_native_header_and_one_bullet_per_skill() {
102        let block = render_available_skills_block(&[skill("foo", "the foo skill")]);
103        assert!(block.contains("The following skills are available for use with the Skill tool:"));
104        assert!(block.contains("- foo: the foo skill"));
105        // The eval-flavored wording and custom format must be gone.
106        assert!(!block.contains("staged and discoverable"));
107        assert!(!block.contains("*Trigger:*"));
108    }
109
110    #[test]
111    fn sorts_skills_by_name() {
112        let block = render_available_skills_block(&[skill("zebra", "z"), skill("alpha", "a")]);
113        assert!(block.find("- alpha:").unwrap() < block.find("- zebra:").unwrap());
114    }
115
116    #[test]
117    fn empty_list_renders_empty_string() {
118        assert_eq!(render_available_skills_block(&[]), "");
119    }
120
121    #[test]
122    fn plan_mode_wraps_in_system_reminder() {
123        let block = render_plan_mode_context("Plan mode is active. Do not edit.");
124        assert!(block.contains("<system-reminder>"));
125        assert!(block.contains("</system-reminder>"));
126        assert!(block.contains("Plan mode is active. Do not edit."));
127    }
128
129    #[test]
130    fn plan_mode_trims_surrounding_whitespace() {
131        let block = render_plan_mode_context("\n\n  PROFILE-BODY  \n\n");
132        assert_eq!(block, "<system-reminder>\nPROFILE-BODY\n</system-reminder>");
133    }
134
135    #[test]
136    fn plan_mode_empty_or_whitespace_renders_empty_string() {
137        assert_eq!(render_plan_mode_context(""), "");
138        assert_eq!(render_plan_mode_context("   \n  "), "");
139    }
140
141    #[test]
142    fn slugify_matches_claude_code_double_hyphen() {
143        // Verified against a real Claude Code project dir: the `/` before `.config`
144        // and the `.` both become `-`, producing a double hyphen.
145        assert_eq!(
146            slugify_project_path(Path::new("/Users/maxhaarhaus/.config/opencode")),
147            "-Users-maxhaarhaus--config-opencode"
148        );
149    }
150
151    #[test]
152    fn slugify_replaces_all_non_alphanumerics_keeping_alnum() {
153        assert_eq!(
154            slugify_project_path(Path::new("/a-b/c.d_e f")),
155            "-a-b-c-d-e-f"
156        );
157        assert_eq!(slugify_project_path(Path::new("/Proj9/v2")), "-Proj9-v2");
158    }
159
160    /// Create `<config>/projects/<dir>/<sid>/subagents/` and return the subagents path.
161    fn make_subagents(config: &Path, project_dir: &str, sid: &str) -> std::path::PathBuf {
162        let dir = config
163            .join("projects")
164            .join(project_dir)
165            .join(sid)
166            .join("subagents");
167        fs::create_dir_all(&dir).unwrap();
168        dir
169    }
170
171    #[test]
172    fn resolve_finds_primary_cwd_slug_path() {
173        let tmp = TempDir::new().unwrap();
174        let cwd = Path::new("/tmp/proj");
175        let sid = "5ade3f59-dda3-4f40-8776-79f82ba0fab2";
176        let expected = make_subagents(tmp.path(), "-tmp-proj", sid);
177        assert_eq!(
178            resolve_subagents_dir_for_session(tmp.path(), cwd, sid),
179            Some(expected)
180        );
181    }
182
183    #[test]
184    fn resolve_falls_back_to_scan_when_cwd_slug_differs() {
185        let tmp = TempDir::new().unwrap();
186        let cwd = Path::new("/tmp/proj"); // slug `-tmp-proj` is NOT created
187        let sid = "11111111-2222-3333-4444-555555555555";
188        let expected = make_subagents(tmp.path(), "some-other-project-slug", sid);
189        assert_eq!(
190            resolve_subagents_dir_for_session(tmp.path(), cwd, sid),
191            Some(expected)
192        );
193    }
194
195    #[test]
196    fn resolve_prefers_primary_over_scan_match() {
197        let tmp = TempDir::new().unwrap();
198        let cwd = Path::new("/tmp/proj");
199        let sid = "99999999-aaaa-bbbb-cccc-dddddddddddd";
200        // A scan candidate that sorts first, plus the cwd-slug primary.
201        make_subagents(tmp.path(), "aaa-other", sid);
202        let primary = make_subagents(tmp.path(), "-tmp-proj", sid);
203        assert_eq!(
204            resolve_subagents_dir_for_session(tmp.path(), cwd, sid),
205            Some(primary)
206        );
207    }
208
209    #[test]
210    fn resolve_none_when_session_dir_absent() {
211        let tmp = TempDir::new().unwrap();
212        fs::create_dir_all(tmp.path().join("projects")).unwrap();
213        assert_eq!(
214            resolve_subagents_dir_for_session(tmp.path(), Path::new("/tmp/proj"), "no-such-sid"),
215            None
216        );
217    }
218
219    #[test]
220    fn resolve_none_when_subagents_subdir_missing() {
221        let tmp = TempDir::new().unwrap();
222        let sid = "abcdabcd-0000-1111-2222-333333333333";
223        // Session dir exists (under the cwd slug) but without a `subagents/` child.
224        fs::create_dir_all(tmp.path().join("projects").join("-tmp-proj").join(sid)).unwrap();
225        assert_eq!(
226            resolve_subagents_dir_for_session(tmp.path(), Path::new("/tmp/proj"), sid),
227            None
228        );
229    }
230}