Skip to main content

eval_magic/adapters/
descriptor_adapter.rs

1//! The generic descriptor-backed [`HarnessAdapter`]: one implementation
2//! serving every harness, reading declarative values from a validated
3//! [`HarnessDescriptor`] and dispatching code-backed features through the
4//! named capabilities in [`super::capabilities`].
5
6use std::io;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use regex::Regex;
11
12use crate::core::{AvailableSkill, HarnessRunCapabilities, ToolInvocation};
13use crate::sandbox::GuardMarker;
14
15use super::TranscriptSummary;
16use super::cli_command::{
17    render_cli_model_arg, render_judge_dispatch_recipe, render_parallel_dispatch_recipe,
18};
19use super::descriptor::{HarnessDescriptor, render_staged_slug, stage_name_error, subst};
20use super::harness::{
21    CliDispatchContext, CliJudgeContext, CliManifestContext, HarnessAdapter, ToolVocabulary,
22};
23use super::skill_shadow::PluginShadowReport;
24use super::skills_block::{DEFAULT_HEADER, DEFAULT_ITEM, render_skills_block};
25
26/// A [`HarnessAdapter`] backed by a validated [`HarnessDescriptor`].
27#[derive(Debug)]
28pub struct DescriptorAdapter {
29    descriptor: HarnessDescriptor,
30    /// Compiled `staging.stage_name_pattern`; validated to compile at load
31    /// time, compiled once here.
32    stage_name_regex: Option<Regex>,
33}
34
35impl DescriptorAdapter {
36    /// Wrap a descriptor that already passed
37    /// [`load_descriptor`](super::descriptor::load_descriptor) (which proves
38    /// the stage-name pattern compiles).
39    pub fn from_descriptor(descriptor: HarnessDescriptor) -> Self {
40        let stage_name_regex = descriptor
41            .staging
42            .stage_name_pattern
43            .as_deref()
44            .map(|pattern| {
45                Regex::new(pattern).expect("stage_name_pattern is validated at descriptor load")
46            });
47        DescriptorAdapter {
48            descriptor,
49            stage_name_regex,
50        }
51    }
52
53    /// The resolved descriptor behind this adapter — the `harness show`/`list`
54    /// data source.
55    pub(crate) fn descriptor(&self) -> &HarnessDescriptor {
56        &self.descriptor
57    }
58
59    /// The single-dispatch command: the exec template with `{model_arg}` /
60    /// `{guard_args}` filled for this run. Empty when no template is wired.
61    fn render_exec_command(&self, guard: bool, agent_model: Option<&str>) -> String {
62        let Some(template) = &self.descriptor.dispatch.exec_template else {
63            return String::new();
64        };
65        let model_arg = render_cli_model_arg(self.model_flag(), agent_model);
66        subst(
67            template,
68            &[
69                ("model_arg", &model_arg),
70                ("guard_args", self.guard_args(guard)),
71            ],
72        )
73    }
74
75    fn render_resume_command(&self, guard: bool, agent_model: Option<&str>) -> Option<String> {
76        let template = &self.descriptor.conversation.as_ref()?.resume_exec_template;
77        let model_arg = render_cli_model_arg(self.model_flag(), agent_model);
78        Some(subst(
79            template,
80            &[
81                ("model_arg", &model_arg),
82                ("guard_args", self.guard_args(guard)),
83            ],
84        ))
85    }
86
87    /// The `{guard_args}` value for this run: the descriptor's fragment when
88    /// the guard is armed, empty otherwise.
89    fn guard_args(&self, guard: bool) -> &str {
90        if guard {
91            self.descriptor.dispatch.guard_args.as_deref().unwrap_or("")
92        } else {
93            ""
94        }
95    }
96
97    fn model_flag(&self) -> Option<&str> {
98        self.descriptor.model.as_ref().map(|m| m.flag.as_str())
99    }
100}
101
102impl HarnessAdapter for DescriptorAdapter {
103    fn label(&self) -> String {
104        self.descriptor.label.clone()
105    }
106
107    fn skills_dir(&self, repo_root: &Path) -> Option<PathBuf> {
108        self.descriptor.skills_dir.as_ref().map(|dir| {
109            dir.split('/')
110                .fold(repo_root.to_path_buf(), |path, segment| path.join(segment))
111        })
112    }
113
114    fn run_capabilities(&self) -> HarnessRunCapabilities {
115        HarnessRunCapabilities {
116            supports_guard: self.descriptor.run.supports_guard,
117            supports_bootstrap_with_no_stage: self.descriptor.run.supports_bootstrap_with_no_stage,
118            supports_stage_name_with_no_stage: self
119                .descriptor
120                .run
121                .supports_stage_name_with_no_stage,
122        }
123    }
124
125    fn config_dir_names(&self) -> Vec<String> {
126        self.descriptor.config_dirs.clone()
127    }
128
129    fn tool_vocabulary(&self) -> ToolVocabulary {
130        ToolVocabulary {
131            write_tools: self.descriptor.tools.write.clone(),
132            patch_tools: self.descriptor.tools.patch.clone(),
133            shell_tools: self.descriptor.tools.shell.clone(),
134            read_tools: self.descriptor.tools.read.clone(),
135        }
136    }
137
138    fn staged_slug(
139        &self,
140        prefix: &str,
141        iteration: u32,
142        condition: &str,
143        skill_name: &str,
144    ) -> String {
145        render_staged_slug(
146            &self.descriptor.staging,
147            prefix,
148            iteration,
149            condition,
150            skill_name,
151        )
152    }
153
154    fn validate_stage_name(&self, name: &str) -> Result<(), String> {
155        match stage_name_error(
156            &self.descriptor.staging,
157            self.stage_name_regex.as_ref(),
158            name,
159        ) {
160            Some(message) => Err(message),
161            None => Ok(()),
162        }
163    }
164
165    fn rewrites_frontmatter_name(&self) -> bool {
166        self.descriptor.staging.rewrites_frontmatter_name
167    }
168
169    fn advertises_staged_slug_name(&self) -> bool {
170        self.descriptor.staging.advertises_staged_slug_name
171    }
172
173    fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
174        match &self.descriptor.skills_block {
175            Some(block) => render_skills_block(&block.header, &block.item, &block.footer, skills),
176            None => render_skills_block(DEFAULT_HEADER, DEFAULT_ITEM, "", skills),
177        }
178    }
179
180    fn skill_surface_phrase(&self) -> String {
181        self.descriptor
182            .staging
183            .surface_phrase
184            .clone()
185            .unwrap_or_else(|| "as a discoverable skill".to_string())
186    }
187
188    fn skill_unresolved_phrase(&self) -> String {
189        self.descriptor
190            .staging
191            .unresolved_phrase
192            .clone()
193            .unwrap_or_else(|| "If the staged skill cannot be resolved".to_string())
194    }
195
196    fn cli_events_filename(&self) -> Option<String> {
197        self.descriptor
198            .transcript
199            .as_ref()
200            .map(|t| t.events_filename.clone())
201    }
202
203    fn parse_cli_events(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
204        match &self.descriptor.transcript {
205            Some(transcript) => transcript.parse(path),
206            None => Err(io::Error::new(
207                io::ErrorKind::Unsupported,
208                format!(
209                    "transcript ingest is not wired for the {} harness",
210                    self.label()
211                ),
212            )),
213        }
214    }
215
216    fn parse_cli_events_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
217        match &self.descriptor.transcript {
218            Some(transcript) => transcript.parse_full(path),
219            None => Err(io::Error::new(
220                io::ErrorKind::Unsupported,
221                format!(
222                    "transcript ingest is not wired for the {} harness",
223                    self.label()
224                ),
225            )),
226        }
227    }
228
229    fn transcript_skill_invocation(&self) -> Option<(String, String)> {
230        match &self.descriptor.transcript {
231            Some(t) if !t.surfaces_skill_invocation => None,
232            Some(t) => Some((
233                t.skill_tool.clone().unwrap_or_else(|| "Skill".to_string()),
234                t.skill_arg.clone().unwrap_or_else(|| "skill".to_string()),
235            )),
236            // No transcript table: the default signature (the meta-check only
237            // fires when a run record carries invocations anyway).
238            None => Some(("Skill".to_string(), "skill".to_string())),
239        }
240    }
241
242    fn cli_model_flag(&self) -> Option<String> {
243        self.model_flag().map(str::to_string)
244    }
245
246    fn install_guard(
247        &self,
248        stage_root: &Path,
249        guard_exe: &Path,
250        ttl: Option<Duration>,
251    ) -> io::Result<PathBuf> {
252        match &self.descriptor.guard {
253            Some(guard) => {
254                let skills_dir = self
255                    .skills_dir(stage_root)
256                    .expect("descriptor validation pairs [guard] with skills_dir");
257                super::guard::install_guard(guard, &skills_dir, stage_root, guard_exe, ttl)
258            }
259            None => Err(io::Error::new(
260                io::ErrorKind::Unsupported,
261                format!("--guard is not supported for the {} harness", self.label()),
262            )),
263        }
264    }
265
266    fn guard_armed_message(&self) -> Option<String> {
267        self.descriptor
268            .guard
269            .as_ref()
270            .map(|g| g.armed_message.clone())
271    }
272
273    fn guard_verdict(&self, payload: &str, marker: Option<GuardMarker>) -> Option<String> {
274        let guard = self.descriptor.guard.as_ref()?;
275        super::guard::guard_verdict(guard, payload, marker)
276    }
277
278    fn guard_hook_cleanup_dir(&self, stage_root: &Path) -> Option<PathBuf> {
279        self.descriptor.guard.as_ref().and_then(|g| {
280            super::guard::hook_cleanup_dir(g, self.descriptor.skills_dir.as_deref(), stage_root)
281        })
282    }
283
284    fn detect_shadowed_skills(
285        &self,
286        scan_root: &Path,
287        staged_skill_names: &[&str],
288    ) -> Option<PluginShadowReport> {
289        self.descriptor
290            .shadow
291            .as_ref()
292            .and_then(|s| s.preflight.detect(scan_root, staged_skill_names))
293    }
294
295    fn format_shadow_banner(&self, report: &PluginShadowReport) -> String {
296        self.descriptor.shadow.as_ref().map_or_else(
297            || super::skill_shadow::format_shadow_banner(report),
298            |shadow| shadow.preflight.format_banner(report),
299        )
300    }
301
302    fn shadow_validity_warnings(&self, report: &PluginShadowReport) -> Vec<String> {
303        self.descriptor.shadow.as_ref().map_or_else(
304            || super::skill_shadow::shadow_validity_warnings(report),
305            |shadow| shadow.preflight.validity_warnings(report),
306        )
307    }
308
309    fn has_dispatch_recipes(&self) -> bool {
310        self.descriptor.dispatch.exec_template.is_some()
311    }
312
313    fn cli_exec_command(&self, guard: bool, agent_model: Option<&str>) -> Option<String> {
314        self.descriptor
315            .dispatch
316            .exec_template
317            .as_ref()
318            .map(|_| self.render_exec_command(guard, agent_model))
319    }
320
321    fn has_conversation_resume(&self) -> bool {
322        self.descriptor.conversation.is_some()
323    }
324
325    fn cli_resume_command(&self, guard: bool, agent_model: Option<&str>) -> Option<String> {
326        self.render_resume_command(guard, agent_model)
327    }
328
329    fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
330        let ingest_line = format!(
331            "ingest{} --iteration {} --harness {}",
332            ctx.target_args, ctx.iteration, self.descriptor.label
333        );
334        let Some(template) = &self.descriptor.dispatch.next_steps_template else {
335            // Generic fallbacks: a descriptor with just an exec template still
336            // earns a copy-pasteable recipe; a baseline descriptor gets the
337            // harness-agnostic handoff.
338            return match &self.descriptor.dispatch.exec_template {
339                Some(_) => format!(
340                    "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task \
341                     with:\n{}\nThen run `{ingest_line}`.",
342                    self.render_exec_command(ctx.guard, ctx.agent_model)
343                ),
344                None => format!(
345                    "\nNext: read dispatch-manifest.md and dispatch each task through your \
346                     harness's one-shot CLI from the task's eval_root, saving the agent's \
347                     final reply to outputs/final-message.md.\nThen run `{ingest_line}`."
348                ),
349            };
350        };
351        let exec_command = self.render_exec_command(ctx.guard, ctx.agent_model);
352        let iteration = ctx.iteration.to_string();
353        let model_note = if ctx.agent_model.is_some() {
354            self.descriptor.dispatch.model_note.as_deref().unwrap_or("")
355        } else {
356            ""
357        };
358        subst(
359            template,
360            &[
361                ("exec_command", &exec_command),
362                ("target_args", ctx.target_args),
363                ("iteration", &iteration),
364                ("model_note", model_note),
365            ],
366        )
367    }
368
369    fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
370        let Some(template) = self.descriptor.dispatch.manifest_template.as_ref() else {
371            // An exec template without a manifest template still earns a
372            // generic recipe section; without either, the manifest's shared
373            // header text already covers the baseline handoff.
374            self.descriptor.dispatch.exec_template.as_ref()?;
375            let exec_command = self.render_exec_command(ctx.guard, ctx.agent_model);
376            return Some(
377                format!(
378                    "## Dispatch recipe\n\nFrom each task's `eval_root`, dispatch with:\n\
379                     {exec_command}\n\nEnsure the agent's final reply lands in the task's \
380                     `outputs/final-message.md` (capture it yourself if the command does not \
381                     write it).\n"
382                )
383                .split('\n')
384                .map(String::from)
385                .collect(),
386            );
387        };
388        let exec_command = self.render_exec_command(ctx.guard, ctx.agent_model);
389        let parallel_recipe = match &self.descriptor.dispatch.parallel_command_template {
390            Some(block_template) => {
391                let model_arg = render_cli_model_arg(self.model_flag(), ctx.agent_model);
392                render_parallel_dispatch_recipe(
393                    &subst(
394                        block_template,
395                        &[
396                            ("model_arg", &model_arg),
397                            ("guard_args", self.guard_args(ctx.guard)),
398                        ],
399                    ),
400                    ctx.one_shot_only,
401                )
402            }
403            None => String::new(),
404        };
405        Some(
406            subst(
407                template,
408                &[
409                    ("exec_command", &exec_command),
410                    ("parallel_recipe", &parallel_recipe),
411                ],
412            )
413            .split('\n')
414            .map(String::from)
415            .collect(),
416        )
417    }
418
419    fn cli_judge_next_steps(&self, ctx: CliJudgeContext<'_>) -> Option<String> {
420        let template = self.descriptor.dispatch.judge_command_template.as_ref()?;
421        let cwd = ctx.iteration_dir.display().to_string();
422        let command_line = subst(
423            template,
424            &[("cwd", &cwd), ("guard_args", self.guard_args(ctx.guard))],
425        );
426        Some(render_judge_dispatch_recipe(
427            &command_line,
428            // Both guaranteed by descriptor validation when the template is set.
429            self.model_flag().unwrap_or_default(),
430            self.descriptor
431                .dispatch
432                .capture_prefix
433                .as_deref()
434                .unwrap_or_default(),
435        ))
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use std::path::Path;
442
443    use crate::adapters::harness::{CliDispatchContext, CliJudgeContext};
444    use crate::adapters::registry::adapter_for;
445    use crate::core::{AvailableSkill, Harness};
446
447    fn skill(name: &str, description: &str) -> AvailableSkill {
448        AvailableSkill {
449            name: name.into(),
450            path: format!("/x/{name}/SKILL.md"),
451            description: description.into(),
452        }
453    }
454
455    fn next_steps(harness: Harness, agent_model: Option<&str>) -> String {
456        adapter_for(harness).cli_next_steps(CliDispatchContext {
457            guard: harness == Harness::resolve("codex").unwrap(),
458            target_args: " --skill-dir /tmp/skills --skill widget-skill",
459            iteration: 2,
460            agent_model,
461        })
462    }
463
464    fn adapter_from(toml_src: &str) -> super::DescriptorAdapter {
465        super::DescriptorAdapter::from_descriptor(
466            crate::adapters::descriptor::load_descriptor(toml_src, "test.toml").unwrap(),
467        )
468    }
469
470    #[test]
471    fn exec_template_alone_yields_generic_dispatch_recipes() {
472        use crate::adapters::harness::{CliManifestContext, HarnessAdapter};
473        let adapter = adapter_from(
474            "label = \"cool-custom-harness\"\n\n[dispatch]\nexec_template = \"cool-cli run <dispatch_prompt_path>{model_arg}\"\n",
475        );
476        let next = adapter.cli_next_steps(CliDispatchContext {
477            guard: false,
478            target_args: " --skill-dir /s --skill x",
479            iteration: 3,
480            agent_model: None,
481        });
482        assert!(next.contains("cool-cli run"), "{next}");
483        assert!(
484            next.contains(
485                "ingest --skill-dir /s --skill x --iteration 3 --harness cool-custom-harness"
486            ),
487            "{next}"
488        );
489        let manifest = adapter
490            .cli_manifest_section(CliManifestContext {
491                guard: false,
492                agent_model: None,
493                one_shot_only: false,
494            })
495            .expect("an exec template earns a generic manifest recipe")
496            .join("\n");
497        assert!(manifest.contains("cool-cli run"), "{manifest}");
498        assert!(manifest.contains("final-message.md"), "{manifest}");
499    }
500
501    #[test]
502    fn built_in_harnesses_render_native_resume_commands() {
503        for harness in [
504            Harness::resolve("claude-code").unwrap(),
505            Harness::resolve("codex").unwrap(),
506            Harness::resolve("opencode").unwrap(),
507        ] {
508            let adapter = adapter_for(harness);
509            assert!(
510                adapter.has_conversation_resume(),
511                "{} should support scripted follow-up turns",
512                adapter.label()
513            );
514            let command = adapter
515                .cli_resume_command(false, Some("test-model"))
516                .expect("built-in resume command");
517            assert!(command.contains("<eval-root>"), "{command}");
518            assert!(command.contains("<outputs_dir>"), "{command}");
519            assert!(command.contains("{session_arg}"), "{command}");
520            assert!(command.contains("{prompt_arg}"), "{command}");
521            assert!(command.contains("test-model"), "{command}");
522        }
523    }
524
525    #[test]
526    fn baseline_descriptor_yields_the_generic_handoff() {
527        use crate::adapters::harness::{CliManifestContext, HarnessAdapter};
528        let adapter = adapter_from("label = \"cool-custom-harness\"\n");
529        let next = adapter.cli_next_steps(CliDispatchContext {
530            guard: false,
531            target_args: " --skill x",
532            iteration: 1,
533            agent_model: None,
534        });
535        assert!(next.contains("one-shot CLI"), "{next}");
536        assert!(next.contains("outputs/final-message.md"), "{next}");
537        assert!(
538            next.contains("ingest --skill x --iteration 1 --harness cool-custom-harness"),
539            "{next}"
540        );
541        assert!(
542            adapter
543                .cli_manifest_section(CliManifestContext {
544                    guard: false,
545                    agent_model: None,
546                    one_shot_only: false,
547                })
548                .is_none(),
549            "the manifest's generic header already covers the no-recipe baseline"
550        );
551    }
552
553    #[test]
554    fn exec_recipe_includes_model_only_when_declared() {
555        let with = next_steps(Harness::resolve("claude-code").unwrap(), Some("opus"));
556        assert!(with.contains("--model opus"), "{with}");
557        let without = next_steps(Harness::resolve("claude-code").unwrap(), None);
558        assert!(!without.contains("--model "), "{without}");
559    }
560
561    #[test]
562    fn codex_recipes_gate_hook_trust_on_guard() {
563        let guarded = next_steps(Harness::resolve("codex").unwrap(), Some("gpt-5-mini"));
564        assert!(
565            guarded.contains(
566                "codex --ask-for-approval never exec --cd <eval-root> --sandbox workspace-write --dangerously-bypass-hook-trust -m gpt-5-mini --json \\"
567            ),
568            "{guarded}"
569        );
570        let unguarded =
571            adapter_for(Harness::resolve("codex").unwrap()).cli_next_steps(CliDispatchContext {
572                guard: false,
573                target_args: "",
574                iteration: 2,
575                agent_model: None,
576            });
577        assert!(
578            !unguarded.contains("--dangerously-bypass-hook-trust"),
579            "{unguarded}"
580        );
581    }
582
583    #[test]
584    fn opencode_exec_recipe_carries_dir_auto_and_the_model_flag() {
585        let with = next_steps(
586            Harness::resolve("opencode").unwrap(),
587            Some("opencode/gpt-5-nano"),
588        );
589        assert!(
590            with.contains(
591                "opencode run --dir <eval-root> --format json --auto -m opencode/gpt-5-nano \\"
592            ),
593            "{with}"
594        );
595        let without = next_steps(Harness::resolve("opencode").unwrap(), None);
596        assert!(
597            without.contains("opencode run --dir <eval-root> --format json --auto \\"),
598            "{without}"
599        );
600        assert!(!without.contains(" -m "), "{without}");
601    }
602
603    #[test]
604    fn codex_judge_recipe_splices_model_arg_in_one_command_shape() {
605        let recipe = adapter_for(Harness::resolve("codex").unwrap())
606            .cli_judge_next_steps(CliJudgeContext {
607                guard: true,
608                iteration_dir: Path::new("/work/iter-1"),
609            })
610            .expect("codex judge recipe is wired");
611        // One command shape: the optional model flag is spliced via $model_arg
612        // (same structure as the Claude judge recipe), not an if/else pair.
613        assert!(
614            recipe.contains(
615                "    codex --ask-for-approval never exec --cd \"/work/iter-1\" --sandbox workspace-write --dangerously-bypass-hook-trust $model_arg --json \\"
616            ),
617            "{recipe}"
618        );
619        assert!(
620            recipe.contains("    model_arg=\"\"; [ -n \"$model\" ] && model_arg=\"-m $model\""),
621            "{recipe}"
622        );
623        assert!(!recipe.contains("if [ -n"), "{recipe}");
624    }
625
626    #[test]
627    fn claude_judge_recipe_snapshot_is_stable() {
628        // Full-string pin carried over from the pre-descriptor adapter: locks
629        // the Claude judge recipe byte-for-byte through the descriptor path.
630        let recipe = adapter_for(Harness::resolve("claude-code").unwrap())
631            .cli_judge_next_steps(CliJudgeContext {
632                guard: false,
633                iteration_dir: Path::new("/work/iter-1"),
634            })
635            .expect("claude judge recipe is wired");
636        let expected = r#"Dispatch each judge task from judge-tasks.json with:
637
638```bash
639JOBS=${JOBS:-4}
640jq -j '.tasks[] | [.dispatch_prompt_path, .response_path, (.model // "")] | @tsv + "\u0000"' judge-tasks.json | \
641  xargs -0 -P "$JOBS" -I{} sh -c '
642    prompt_path="$(printf "%s" "$1" | cut -f1)"
643    response_path="$(printf "%s" "$1" | cut -f2)"
644    model="$(printf "%s" "$1" | cut -f3)"
645    response_base="${response_path%.json}"
646    mkdir -p "$(dirname "$response_path")"
647    model_arg=""; [ -n "$model" ] && model_arg="--model $model"
648    cd "/work/iter-1" && claude -p --output-format stream-json --verbose --permission-mode acceptEdits $model_arg \
649      "Read the file at $prompt_path and follow it exactly. You are a judge worker only: write the JSON verdict to $response_path, then reply with one sentence. Do not run eval-magic. Do not dispatch other judge tasks. Do not wait for other workers." \
650      </dev/null \
651      > "$response_base.claude-events.jsonl" \
652      2> "$response_base.claude-stderr.log"
653  ' sh {}
654```"#;
655        assert_eq!(recipe, expected);
656    }
657
658    #[test]
659    fn skills_blocks_render_each_harness_native_shape() {
660        let skills = vec![skill("zebra", "z skill"), skill("alpha", "a skill")];
661
662        let claude = adapter_for(Harness::resolve("claude-code").unwrap())
663            .render_available_skills_block(&skills);
664        assert!(
665            claude.starts_with("The following skills are available for use with the Skill tool:"),
666            "{claude}"
667        );
668        assert!(claude.contains("\n- alpha: a skill"), "{claude}");
669
670        let codex =
671            adapter_for(Harness::resolve("codex").unwrap()).render_available_skills_block(&skills);
672        assert!(codex.starts_with("## Skills"), "{codex}");
673        assert!(
674            codex.contains("- alpha: a skill (file: /x/alpha/SKILL.md)"),
675            "{codex}"
676        );
677
678        let opencode = adapter_for(Harness::resolve("opencode").unwrap())
679            .render_available_skills_block(&skills);
680        assert!(opencode.starts_with("<available_skills>"), "{opencode}");
681        assert!(opencode.ends_with("\n</available_skills>"), "{opencode}");
682        assert!(opencode.contains("<name>alpha</name>"), "{opencode}");
683        assert!(
684            opencode.contains("<description>a skill</description>"),
685            "{opencode}"
686        );
687
688        // Sorted by name in every shape, and empty renders empty.
689        for harness in Harness::known() {
690            let block = adapter_for(harness).render_available_skills_block(&skills);
691            assert!(
692                block.find("alpha").unwrap() < block.find("zebra").unwrap(),
693                "{harness:?} sorts by name: {block}"
694            );
695            assert_eq!(adapter_for(harness).render_available_skills_block(&[]), "");
696        }
697    }
698
699    #[test]
700    fn opencode_stage_name_rules_match_the_old_validator() {
701        let adapter = adapter_for(Harness::resolve("opencode").unwrap());
702        assert!(adapter.validate_stage_name("valid-name-2").is_ok());
703        for invalid in [
704            "Invalid_Name",
705            "double--hyphen",
706            "-leading",
707            "trailing-",
708            "",
709        ] {
710            let err = adapter
711                .validate_stage_name(invalid)
712                .expect_err("name should be rejected");
713            assert!(
714                err.contains(&format!("OpenCode skill name \"{invalid}\" is invalid")),
715                "{err}"
716            );
717        }
718        assert!(adapter.validate_stage_name(&"a".repeat(64)).is_ok());
719        assert!(adapter.validate_stage_name(&"a".repeat(65)).is_err());
720    }
721}