1use 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#[derive(Debug)]
29pub struct DescriptorAdapter {
30 descriptor: HarnessDescriptor,
31 stage_name_regex: Option<Regex>,
34}
35
36impl DescriptorAdapter {
37 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 pub(crate) fn descriptor(&self) -> &HarnessDescriptor {
57 &self.descriptor
58 }
59
60 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 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 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 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 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", ¶llel_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 &[("cwd", &cwd), ("guard_args", self.guard_args(false))],
437 );
438 Some(render_judge_dispatch_recipe(
439 &command_line,
440 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 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 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 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}