eval_magic/adapters/opencode/
mod.rs1pub mod skill_shadow;
11pub mod transcript;
12
13fn 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
35fn 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
66pub(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 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}