Skip to main content

eval_magic/adapters/opencode/
mod.rs

1//! OpenCode harness support.
2//!
3//! The declarative half of this harness lives in `harnesses/opencode.toml`
4//! (including the stage-name rules, expressed as a regex + length cap); this
5//! module tree keeps only the code-backed capabilities the descriptor
6//! references: slug sanitization/truncation (the `opencode` slug capability),
7//! `--format json` event-stream parsing ([`transcript`]), and live-skill
8//! shadow detection ([`skill_shadow`]).
9
10pub mod skill_shadow;
11pub mod transcript;
12
13/// True when `name` satisfies OpenCode's skill-name rules:
14/// - 1–64 characters
15/// - lowercase alphanumeric with single-hyphen separators
16/// - no leading/trailing/consecutive hyphens
17fn is_valid_opencode_name(name: &str) -> bool {
18    if name.is_empty() || name.len() > 64 {
19        return false;
20    }
21    let mut prev = '-';
22    for ch in name.chars() {
23        if ch == '-' {
24            if prev == '-' {
25                return false;
26            }
27        } else if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() {
28            return false;
29        }
30        prev = ch;
31    }
32    !name.starts_with('-') && !name.ends_with('-')
33}
34
35/// Sanitize an arbitrary identifier so it is a valid OpenCode skill name.
36fn sanitize_opencode_name(name: &str) -> String {
37    let mut out = String::new();
38    let mut prev_hyphen = false;
39    for ch in name.to_ascii_lowercase().chars() {
40        if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
41            out.push(ch);
42            prev_hyphen = false;
43        } else if !prev_hyphen {
44            out.push('-');
45            prev_hyphen = true;
46        }
47    }
48    while out.ends_with('-') {
49        out.pop();
50    }
51    while out.starts_with('-') {
52        out.remove(0);
53    }
54    if out.is_empty() {
55        out.push_str("skill");
56    }
57    if out.len() > 64 {
58        out.truncate(64);
59        while out.ends_with('-') {
60            out.pop();
61        }
62    }
63    out
64}
65
66/// Build a slug that is valid for OpenCode's skill directory + frontmatter name
67/// constraints. `prefix` is the conspicuous staged-skill prefix, preserved so
68/// cleanup prefix-scans still find it. `pub(crate)` because the `opencode`
69/// named slug capability dispatches here.
70pub(crate) fn opencode_slug(
71    prefix: &str,
72    iteration: u32,
73    condition: &str,
74    skill_name: &str,
75) -> String {
76    let condition = sanitize_opencode_name(condition);
77    let skill = sanitize_opencode_name(skill_name);
78    let base = format!("{prefix}{iteration}-{condition}-{skill}");
79    if base.len() <= 64 && is_valid_opencode_name(&base) {
80        return base;
81    }
82    // If the combined slug is too long, truncate the skill portion.
83    let prefix = format!("{prefix}{iteration}-{condition}-");
84    let budget = 64usize.saturating_sub(prefix.len());
85    let mut truncated = skill.clone();
86    truncated.truncate(budget);
87    while truncated.ends_with('-') {
88        truncated.pop();
89    }
90    if truncated.is_empty() {
91        truncated.push_str("skill");
92    }
93    format!("{prefix}{truncated}")
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    const PREFIX: &str = "slow-powers-eval-";
101
102    #[test]
103    fn opencode_slug_sanitizes_underscores_and_special_characters() {
104        assert_eq!(
105            opencode_slug(PREFIX, 1, "with_skill", "My_Skill!"),
106            "slow-powers-eval-1-with-skill-my-skill"
107        );
108        assert_eq!(
109            opencode_slug(PREFIX, 2, "without_skill", "snake_case"),
110            "slow-powers-eval-2-without-skill-snake-case"
111        );
112    }
113
114    #[test]
115    fn opencode_slug_truncates_to_valid_max_length() {
116        let very_long = "a".repeat(200);
117        let slug = opencode_slug(PREFIX, 1, "with_skill", &very_long);
118        assert!(slug.len() <= 64);
119        assert!(is_valid_opencode_name(&slug));
120        assert!(slug.starts_with("slow-powers-eval-1-with-skill-"));
121    }
122
123    #[test]
124    fn opencode_parse_cli_events_delegates_to_events_parser() {
125        use serde_json::json;
126        let dir = tempfile::TempDir::new().unwrap();
127        let path = dir.path().join("opencode-events.jsonl");
128        let line = json!({"type": "tool_use", "timestamp": 1_000, "sessionID": "ses_1", "part": {"id": "p1", "type": "tool", "tool": "bash", "state": {"status": "completed", "input": {"command": "bun test"}, "output": "ok", "title": "bash", "metadata": {}, "time": {"start": 900, "end": 1_000}}}});
129        std::fs::write(&path, format!("{line}\n")).unwrap();
130
131        let inv = crate::adapters::adapter_for(crate::core::Harness::resolve("opencode").unwrap())
132            .parse_cli_events(&path)
133            .unwrap();
134        assert_eq!(inv.len(), 1);
135        assert_eq!(inv[0].name, "bash");
136        assert_eq!(
137            inv[0].args,
138            Some(json!({"command": "bun test"})),
139            "args are the tool part's state.input"
140        );
141    }
142}