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