Skip to main content

eval_magic/adapters/
opencode_session.rs

1//! OpenCode-specific rendering of session-start context.
2//!
3//! OpenCode exposes discoverable skills through the `skill` tool description as
4//! `<available_skills>` XML, and loads them from `.opencode/skills/`. This
5//! adapter mirrors that native presentation so eval dispatches feel like a real
6//! OpenCode session rather than an eval-specific bulletin.
7
8use crate::core::AvailableSkill;
9
10/// Render the discoverable skills the way OpenCode surfaces them in the `skill`
11/// tool description: an `<available_skills>` block with one `<skill>` element
12/// per skill containing `<name>` and `<description>`. Returns an empty string
13/// when no skills are staged.
14pub fn render_opencode_available_skills_block(skills: &[AvailableSkill]) -> String {
15    if skills.is_empty() {
16        return String::new();
17    }
18    let mut sorted: Vec<&AvailableSkill> = skills.iter().collect();
19    sorted.sort_by(|a, b| a.name.cmp(&b.name));
20    let mut out = String::from("<available_skills>");
21    for s in sorted {
22        out.push_str(&format!(
23            "\n  <skill>\n    <name>{}</name>\n    <description>{}</description>\n  </skill>",
24            s.name, s.description
25        ));
26    }
27    out.push_str("\n</available_skills>");
28    out
29}
30
31/// Render an OpenCode plan-mode profile as an operating-context reminder. The
32/// real OpenCode plan agent is a primary agent mode, not text, so this is the
33/// portable approximation: a `<system-reminder>` the dispatch reads.
34pub fn render_opencode_plan_mode_context(profile_text: &str) -> String {
35    let trimmed = profile_text.trim();
36    if trimmed.is_empty() {
37        return String::new();
38    }
39    format!("<system-reminder>\n{trimmed}\n</system-reminder>")
40}
41
42#[cfg(test)]
43mod tests {
44    use super::*;
45    use crate::core::AvailableSkill;
46
47    fn skill(name: &str, description: &str) -> AvailableSkill {
48        AvailableSkill {
49            name: name.into(),
50            path: format!("/x/{name}/SKILL.md"),
51            description: description.into(),
52        }
53    }
54
55    #[test]
56    fn renders_opencode_xml_with_name_and_description() {
57        let block =
58            render_opencode_available_skills_block(&[skill("git-release", "Create releases")]);
59        assert!(block.contains("<available_skills>"));
60        assert!(block.contains("</available_skills>"));
61        assert!(block.contains("<name>git-release</name>"));
62        assert!(block.contains("<description>Create releases</description>"));
63        assert!(block.contains("<skill>"));
64        assert!(block.contains("</skill>"));
65    }
66
67    #[test]
68    fn sorts_skills_by_name() {
69        let block =
70            render_opencode_available_skills_block(&[skill("zebra", "z"), skill("alpha", "a")]);
71        assert!(
72            block.find("<name>alpha</name>").unwrap() < block.find("<name>zebra</name>").unwrap()
73        );
74    }
75
76    #[test]
77    fn empty_list_renders_empty_string() {
78        assert_eq!(render_opencode_available_skills_block(&[]), "");
79    }
80
81    #[test]
82    fn plan_mode_wraps_in_system_reminder() {
83        let block = render_opencode_plan_mode_context("OpenCode plan mode is active.");
84        assert_eq!(
85            block,
86            "<system-reminder>\nOpenCode plan mode is active.\n</system-reminder>"
87        );
88    }
89
90    #[test]
91    fn plan_mode_trims_surrounding_whitespace() {
92        let block = render_opencode_plan_mode_context("\n\n  PROFILE-BODY  \n\n");
93        assert_eq!(block, "<system-reminder>\nPROFILE-BODY\n</system-reminder>");
94    }
95
96    #[test]
97    fn plan_mode_empty_or_whitespace_renders_empty_string() {
98        assert_eq!(render_opencode_plan_mode_context(""), "");
99        assert_eq!(render_opencode_plan_mode_context("   \n  "), "");
100    }
101}