eval_magic/adapters/harness.rs
1//! The harness adapter API — the single seam between generic dispatch code and
2//! harness-specific behavior.
3//!
4//! The trait is tiered into a **baseline** every harness must implement and
5//! **enhancements** that raise fidelity when a harness has the native support:
6//!
7//! - **Baseline (required):** [`label`](HarnessAdapter::label) and
8//! [`skills_dir`](HarnessAdapter::skills_dir). A new harness compiles with
9//! just these two methods; dispatched through its one-shot CLI (with
10//! `--no-stage` inlining the skill when native staging isn't wired), it
11//! already supports `llm_judge` grading and the `detect-stray-writes`
12//! post-pass.
13//! - **Enhancements (defaulted):** every other method has a default — either a
14//! working generic fallback (e.g. the plain available-skills block) or an
15//! `Unsupported` error naming the enhancement it belongs to (e.g. transcript
16//! ingest, the write guard). Override the methods of an enhancement to wire
17//! it for a harness.
18//!
19//! Generic code resolves an adapter with [`adapter_for`](super::registry::adapter_for)
20//! and then calls the trait — so the [`registry`](super::registry) is the one
21//! place that names a concrete harness for this surface. The impls live in the
22//! per-harness modules ([`claude_code`](super::claude_code),
23//! [`codex`](super::codex), [`opencode`](super::opencode)).
24
25use std::io;
26use std::path::{Path, PathBuf};
27use std::time::Duration;
28
29use crate::core::{AvailableSkill, HarnessRunCapabilities, ToolInvocation};
30use crate::sandbox::GuardMarker;
31
32use super::TranscriptSummary;
33use super::skill_shadow::PluginShadowReport;
34
35/// One harness's tool-name vocabulary: every name its guard hook payloads or
36/// transcript parser can produce, grouped by role. Consumers match against the
37/// union across all harnesses ([`all_tool_vocabulary`](super::registry::all_tool_vocabulary)).
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct ToolVocabulary {
40 /// Tools that write the filesystem with a single target path argument.
41 pub write_tools: Vec<String>,
42 /// apply_patch-style tools whose payload carries multiple patch targets.
43 pub patch_tools: Vec<String>,
44 /// Shell-execution tools carrying a `command` argument.
45 pub shell_tools: Vec<String>,
46 /// Read-only tools carrying a target path argument.
47 pub read_tools: Vec<String>,
48}
49
50/// How per-turn token totals combine for a native resumed conversation.
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
52#[serde(rename_all = "lowercase")]
53pub enum TokenUsageAggregation {
54 /// Each turn reports only its own token usage.
55 #[default]
56 Sum,
57 /// Each turn reports cumulative usage for the native session.
58 Last,
59}
60
61/// The behavior that varies by harness. Generic dispatch code depends on this
62/// trait, never on a concrete harness variant. See the module docs for the
63/// baseline-vs-enhancement contract.
64pub trait HarnessAdapter {
65 // ── Baseline (required) — every harness implements these ────────────────
66
67 /// **Baseline.** The kebab-case identifier used in CLI flags,
68 /// `dispatch.json`, and the staged `conditions.json`.
69 fn label(&self) -> String;
70
71 /// **Baseline.** The project-local directory staged skills live under for
72 /// this harness. Under `--no-stage` nothing is staged into it, so a
73 /// baseline harness may point this at any repo-local path its discovery
74 /// would read. `None` when the harness declares no skills directory —
75 /// native staging is then unavailable and the run preflight forces
76 /// `--no-stage` (each SKILL.md is inlined into its dispatch prompt).
77 fn skills_dir(&self, repo_root: &Path) -> Option<PathBuf>;
78
79 // ── Run-option capabilities (defaulted) ──────────────────────────────────
80
81 /// The run options the generic `run` preflight may accept for this
82 /// harness. The default is the baseline: no write guard, `--bootstrap` and
83 /// `--stage-name` allowed alongside `--no-stage`. Override alongside the
84 /// enhancement that changes support (e.g. wiring the write guard flips
85 /// `supports_guard`).
86 fn run_capabilities(&self) -> HarnessRunCapabilities {
87 HarnessRunCapabilities {
88 supports_guard: false,
89 supports_bootstrap_with_no_stage: true,
90 supports_stage_name_with_no_stage: true,
91 }
92 }
93
94 /// The project-local config dir names this harness reads or the adapter
95 /// writes (e.g. `.claude`). Staging excludes every harness's config dirs
96 /// when copying a skill's sibling assets, so a stray checked-in config dir
97 /// never rides into a staged env. Via
98 /// [`all_config_dir_names`](super::registry::all_config_dir_names) this list
99 /// also feeds the guard's Bash tamper rule and detect-stray-writes'
100 /// staging-dir lookbehind, so adding a dir here automatically grows the
101 /// write-guard's deny surface. List the parent of
102 /// [`skills_dir`](Self::skills_dir) plus any hook/config dirs the adapter
103 /// writes.
104 fn config_dir_names(&self) -> Vec<String> {
105 Vec::new()
106 }
107
108 /// The tool names this harness's guard hook payloads and parsed transcripts
109 /// use, grouped by role. Via
110 /// [`all_tool_vocabulary`](super::registry::all_tool_vocabulary) this feeds the guard
111 /// arbiter's tool classification and detect-stray-writes' invocation audit,
112 /// so list every name this harness's surfaces produce — even names another
113 /// harness also uses; the union dedups. Default empty: a harness with no
114 /// guard and no transcript parser contributes nothing.
115 fn tool_vocabulary(&self) -> ToolVocabulary {
116 ToolVocabulary::default()
117 }
118
119 // ── Enhancement: native skill staging (defaulted) ────────────────────────
120 // Fallback without it: `--no-stage` inlines each SKILL.md into its
121 // dispatch prompt instead of staging files for native discovery.
122
123 /// **Enhancement: native staging.** Build the conspicuous staged-skill
124 /// slug. The default underscore form is fine for any harness without
125 /// naming rules; a harness with constrained skill names (e.g. OpenCode)
126 /// overrides it. `prefix` must be preserved so cleanup prefix-scans still
127 /// find the staged dir.
128 fn staged_slug(
129 &self,
130 prefix: &str,
131 iteration: u32,
132 condition: &str,
133 skill_name: &str,
134 ) -> String {
135 format!("{prefix}{iteration}-{condition}__{skill_name}")
136 }
137
138 /// **Enhancement: native staging.** Validate a staged-skill identifier
139 /// (generated slug or `--stage-name` override) against this harness's
140 /// naming rules. The default accepts anything.
141 fn validate_stage_name(&self, _name: &str) -> Result<(), String> {
142 Ok(())
143 }
144
145 /// **Enhancement: native staging.** Whether a staged skill's frontmatter
146 /// `name:` is rewritten to its slug so the harness's repo-local discovery
147 /// resolves the staged copy.
148 fn rewrites_frontmatter_name(&self) -> bool {
149 false
150 }
151
152 /// **Enhancement: native staging.** Whether the skill-under-test is
153 /// advertised in the available-skills block under its staged slug (vs. its
154 /// natural name). True for Codex and OpenCode, whose repo-local discovery
155 /// keys on the rewritten frontmatter name.
156 fn advertises_staged_slug_name(&self) -> bool {
157 false
158 }
159
160 /// **Enhancement: native staging.** Render the discoverable skills the way
161 /// this harness natively surfaces them (e.g. Claude Code's Skill-tool
162 /// list, Codex's `## Skills`, OpenCode's `<available_skills>` XML). The
163 /// default is a neutral bulleted list.
164 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
165 super::skills_block::render_skills_block(
166 super::skills_block::DEFAULT_HEADER,
167 super::skills_block::DEFAULT_ITEM,
168 "",
169 skills,
170 )
171 }
172
173 /// **Enhancement: native staging.** How a staged skill is described as
174 /// discoverable in the neutral slug-disambiguation line (e.g. "via the
175 /// Skill tool").
176 fn skill_surface_phrase(&self) -> String {
177 "as a discoverable skill".to_string()
178 }
179
180 /// **Enhancement: native staging.** The lead-in for the fallback "read the
181 /// skill from `<path>`" instruction when the staged identifier can't be
182 /// resolved.
183 fn skill_unresolved_phrase(&self) -> String {
184 "If the staged skill cannot be resolved".to_string()
185 }
186
187 // ── Enhancement: transcript parser (defaulted) ───────────────────────────
188 // Fallback without it: `transcript_check` assertions grade as
189 // unverifiable, `llm_judge` carries the grading, token/cost/duration go
190 // unrecorded, and run records are assembled by hand (or from
191 // `outputs/final-message.md`) instead of auto-ingested.
192
193 /// **Enhancement: transcript parser.** The filename (under a task's
194 /// `outputs/` dir) this harness's one-shot CLI writes the captured
195 /// transcript to. `None` when no transcript ingest is wired — the ingest
196 /// pipeline then never calls the parsers below.
197 fn cli_events_filename(&self) -> Option<String> {
198 None
199 }
200
201 /// **Enhancement: transcript parser.** Parse the events file this
202 /// harness's one-shot CLI wrote (the captured transcript) into ordered
203 /// tool invocations.
204 fn parse_cli_events(&self, _path: &Path) -> io::Result<Vec<ToolInvocation>> {
205 Err(io::Error::new(
206 io::ErrorKind::Unsupported,
207 format!(
208 "transcript ingest is not wired for the {} harness",
209 self.label()
210 ),
211 ))
212 }
213
214 /// **Enhancement: transcript parser.** The full-summary counterpart of
215 /// [`parse_cli_events`](Self::parse_cli_events): tool invocations, deduped
216 /// token usage, duration, and final message text.
217 fn parse_cli_events_full(&self, _path: &Path) -> io::Result<TranscriptSummary> {
218 Err(io::Error::new(
219 io::ErrorKind::Unsupported,
220 format!(
221 "transcript ingest is not wired for the {} harness",
222 self.label()
223 ),
224 ))
225 }
226
227 /// **Enhancement: transcript parser.** The deterministic skill-invocation
228 /// signature the `__skill_invoked` meta-check matches: `(tool name, arg
229 /// carrying the staged slug)` — Claude Code's `Skill`/`skill`, OpenCode's
230 /// `skill`/`name`. `None` for Codex (its JSONL has no skill-tool event),
231 /// which routes the meta-check to the LLM-judge fallback.
232 fn transcript_skill_invocation(&self) -> Option<(String, String)> {
233 Some(("Skill".to_string(), "skill".to_string()))
234 }
235
236 // ── Enhancement: model flag (defaulted) ──────────────────────────────────
237 // Fallback without it: `--agent-model` / `--judge-model` are recorded as
238 // provenance only; dispatches run on the harness's default model.
239
240 /// **Enhancement: model flag.** The native model-selection flag accepted
241 /// by this harness's CLI. `None` means no model-selection support is
242 /// wired.
243 fn cli_model_flag(&self) -> Option<String> {
244 None
245 }
246
247 // ── Enhancement: write guard (defaulted) ─────────────────────────────────
248 // Fallback without it: the `detect-stray-writes` post-pass (folded into
249 // `ingest`) audits out-of-bounds writes after the fact.
250
251 /// **Enhancement: write guard.** Arm the write guard using this harness's
252 /// native pre-tool hook surface, returning the staged marker path. The
253 /// guard's allowed roots are derived from `stage_root` (the isolated env /
254 /// agent cwd), so it bounds the agent to the same env boundary that
255 /// isolates its reads.
256 fn install_guard(
257 &self,
258 _stage_root: &Path,
259 _guard_exe: &Path,
260 _ttl: Option<Duration>,
261 ) -> io::Result<PathBuf> {
262 Err(io::Error::new(
263 io::ErrorKind::Unsupported,
264 format!("--guard is not supported for the {} harness", self.label()),
265 ))
266 }
267
268 /// **Enhancement: write guard.** The banner printed after `--guard`
269 /// successfully arms, describing the harness's native hook surface and how
270 /// to remove it. `None` for a harness with no write guard (its
271 /// [`install_guard`](Self::install_guard) errors), in which case no banner
272 /// is printed.
273 fn guard_armed_message(&self) -> Option<String> {
274 None
275 }
276
277 /// **Enhancement: write guard.** Evaluate a PreToolUse hook `payload`
278 /// against `marker`, returning the serialized deny verdict to print on
279 /// stdout, or `None` to allow. The default fails open — a harness with no
280 /// guard never denies — matching the hook entry points' contract that a
281 /// guard invocation can never brick a session.
282 fn guard_verdict(&self, _payload: &str, _marker: Option<GuardMarker>) -> Option<String> {
283 None
284 }
285
286 /// **Enhancement: write guard.** A hook-config dir the guard install
287 /// created outside [`skills_dir`](Self::skills_dir) (e.g. Codex's
288 /// `.codex/`), which teardown prunes when restoring the original config
289 /// leaves it empty. `None` when the guard writes only under existing dirs.
290 fn guard_hook_cleanup_dir(&self, _stage_root: &Path) -> Option<PathBuf> {
291 None
292 }
293
294 // ── Enhancement: shadow preflight (defaulted) ────────────────────────────
295 // Fallback without it: no preflight — the run proceeds with no shadow
296 // report, exactly as for a harness whose dispatches load nothing global.
297
298 /// **Enhancement: shadow preflight.** Detect staged skill names that are
299 /// also discoverable from the operator's live environment (e.g. Claude
300 /// Code's enabled plugins or global skills dir), which contaminates the
301 /// with/without comparison. `scan_root` is a real staged env root — its
302 /// project-local settings participate in detection. `None` when the
303 /// harness has no shadow preflight (the default) or nothing is shadowed.
304 fn detect_shadowed_skills(
305 &self,
306 _scan_root: &Path,
307 _staged_skill_names: &[&str],
308 ) -> Option<PluginShadowReport> {
309 None
310 }
311
312 /// **Enhancement: shadow preflight.** Format the runner banner for a
313 /// report. The default preserves the original Claude-oriented rendering
314 /// for third-party adapters and older artifacts.
315 fn format_shadow_banner(&self, report: &PluginShadowReport) -> String {
316 super::skill_shadow::format_shadow_banner(report)
317 }
318
319 /// **Enhancement: shadow preflight.** Format aggregate validity warnings
320 /// for a report. The default preserves the original Claude-oriented
321 /// rendering for third-party adapters and older artifacts.
322 fn shadow_validity_warnings(&self, report: &PluginShadowReport) -> Vec<String> {
323 super::skill_shadow::shadow_validity_warnings(report)
324 }
325
326 // ── Enhancement: plan-mode context (defaulted) ───────────────────────────
327
328 /// **Enhancement: plan-mode context.** Wrap a plan-mode profile as an
329 /// operating-context layer. The shared `<system-reminder>` default
330 /// usually suffices; a harness with a real native plan mode could inject
331 /// it differently.
332 fn render_plan_mode_context(&self, profile_text: &str) -> String {
333 let trimmed = profile_text.trim();
334 if trimmed.is_empty() {
335 return String::new();
336 }
337 format!("<system-reminder>\n{trimmed}\n</system-reminder>")
338 }
339
340 // ── Enhancement: dispatch recipes (defaulted) ────────────────────────────
341 // Fallback without them: `run` prints the generic handoff and the runbook
342 // carries no copy-pasteable per-task command.
343
344 /// **Enhancement: dispatch recipes.** Whether a copy-pasteable per-task
345 /// exec command is wired (the descriptor's `[dispatch] exec_template`).
346 /// `false` means `RUNBOOK.md` / `dispatch-manifest.md` carry handoff
347 /// guidance without a per-task command recipe, and the `run` preflight
348 /// warns naming that limitation.
349 fn has_dispatch_recipes(&self) -> bool {
350 false
351 }
352
353 /// Render the harness's one-shot CLI command for a task. Angle-bracket
354 /// task paths remain for the caller to substitute.
355 fn cli_exec_command(&self, _guard: bool, _agent_model: Option<&str>) -> Option<String> {
356 None
357 }
358
359 /// **Enhancement: native conversation resume.** Whether the harness can
360 /// continue a captured native session for scripted follow-up turns.
361 fn has_conversation_resume(&self) -> bool {
362 false
363 }
364
365 /// How transcript token totals combine across resumed conversation turns.
366 fn conversation_token_usage_aggregation(&self) -> TokenUsageAggregation {
367 TokenUsageAggregation::Sum
368 }
369
370 /// Render one same-session follow-up command. In addition to the usual
371 /// angle-bracket task paths, `{session_arg}` and `{prompt_arg}` remain for
372 /// the conversation driver to fill with shell-quoted values.
373 fn cli_resume_command(&self, _guard: bool, _agent_model: Option<&str>) -> Option<String> {
374 None
375 }
376
377 /// **Enhancement: dispatch recipes.** The `Next:` guidance printed after
378 /// `run`: how to dispatch each task through this harness's one-shot CLI
379 /// and then ingest. Empty when no dispatch recipe is wired.
380 fn cli_next_steps(&self, _ctx: CliDispatchContext<'_>) -> String {
381 String::new()
382 }
383
384 /// **Enhancement: dispatch recipes.** Extra `dispatch-manifest.md` lines
385 /// describing this harness's dispatch recipe (command template, parallel
386 /// recipe, ingest note). `None` when the harness contributes no manifest
387 /// section.
388 fn cli_manifest_section(&self, _ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
389 None
390 }
391
392 /// **Enhancement: dispatch recipes.** The post-`grade` / post-`ingest`
393 /// judge dispatch guidance for this harness. `None` leaves the generic
394 /// judge handoff in place.
395 fn cli_judge_next_steps(&self, _ctx: CliJudgeContext<'_>) -> Option<String> {
396 None
397 }
398}
399
400/// The shared (human-followed) `RUNBOOK.md` template used by every run,
401/// regardless of harness (Claude Code, Codex, OpenCode).
402pub const RUNBOOK_TEMPLATE: &str = include_str!("../../profiles/shared/runbook.md");
403
404/// Context for rendering a harness's one-shot CLI agent-dispatch guidance.
405#[derive(Debug, Clone, Copy)]
406pub struct CliDispatchContext<'a> {
407 pub guard: bool,
408 pub target_args: &'a str,
409 pub iteration: u32,
410 pub agent_model: Option<&'a str>,
411}
412
413/// Context for rendering a harness's `dispatch-manifest.md` CLI recipe.
414#[derive(Debug, Clone, Copy)]
415pub struct CliManifestContext<'a> {
416 pub guard: bool,
417 pub agent_model: Option<&'a str>,
418 /// Exclude scripted tasks from a mixed suite's one-shot recipe.
419 pub one_shot_only: bool,
420}
421
422/// Context for rendering a harness's one-shot CLI judge-dispatch guidance.
423#[derive(Debug, Clone, Copy)]
424pub struct CliJudgeContext<'a> {
425 pub guard: bool,
426 pub iteration_dir: &'a Path,
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::adapters::registry::adapter_for;
433 use crate::core::Harness;
434
435 // Cross-method invariants (guard/banner lockstep, hook-matcher ⊆
436 // vocabulary, slug ↔ naming rules, …) are enforced at descriptor load
437 // time in `descriptor::validation`; only per-value pins live here.
438
439 #[test]
440 fn detect_shadowed_skills_defaults_to_none_for_harnesses_without_a_preflight() {
441 // Every built-in harness declares a shadow preflight today; a
442 // descriptor without [shadow] (the baseline BYOH shape) gets none.
443 let descriptor = crate::adapters::descriptor::load_descriptor(
444 "label = \"demo\"\nskills_dir = \".demo/skills\"\nconfig_dirs = [\".demo\"]\n",
445 "test.toml",
446 )
447 .unwrap();
448 let adapter =
449 crate::adapters::descriptor_adapter::DescriptorAdapter::from_descriptor(descriptor);
450 assert_eq!(
451 adapter.detect_shadowed_skills(Path::new("/nonexistent"), &["any-skill"]),
452 None
453 );
454 }
455
456 #[test]
457 fn has_dispatch_recipes_matches_the_readme_support_table() {
458 assert!(adapter_for(Harness::resolve("claude-code").unwrap()).has_dispatch_recipes());
459 assert!(adapter_for(Harness::resolve("codex").unwrap()).has_dispatch_recipes());
460 assert!(adapter_for(Harness::resolve("opencode").unwrap()).has_dispatch_recipes());
461 }
462
463 #[test]
464 fn skills_dir_is_harness_native() {
465 let root = Path::new("/repo");
466 assert_eq!(
467 adapter_for(Harness::resolve("claude-code").unwrap()).skills_dir(root),
468 Some(root.join(".claude").join("skills"))
469 );
470 assert_eq!(
471 adapter_for(Harness::resolve("codex").unwrap()).skills_dir(root),
472 Some(root.join(".agents").join("skills"))
473 );
474 assert_eq!(
475 adapter_for(Harness::resolve("opencode").unwrap()).skills_dir(root),
476 Some(root.join(".opencode").join("skills"))
477 );
478 }
479
480 #[test]
481 fn only_codex_and_opencode_rewrite_frontmatter() {
482 assert!(!adapter_for(Harness::resolve("claude-code").unwrap()).rewrites_frontmatter_name());
483 assert!(adapter_for(Harness::resolve("codex").unwrap()).rewrites_frontmatter_name());
484 assert!(adapter_for(Harness::resolve("opencode").unwrap()).rewrites_frontmatter_name());
485 }
486
487 #[test]
488 fn skill_invocation_signatures_are_harness_native() {
489 // (tool name, slug-carrying arg) the `__skill_invoked` meta-check
490 // matches; None routes the check to the LLM-judge fallback.
491 assert_eq!(
492 adapter_for(Harness::resolve("claude-code").unwrap()).transcript_skill_invocation(),
493 Some(("Skill".to_string(), "skill".to_string()))
494 );
495 assert_eq!(
496 adapter_for(Harness::resolve("codex").unwrap()).transcript_skill_invocation(),
497 None
498 );
499 assert_eq!(
500 adapter_for(Harness::resolve("opencode").unwrap()).transcript_skill_invocation(),
501 Some(("skill".to_string(), "name".to_string()))
502 );
503 }
504
505 #[test]
506 fn plan_mode_context_wraps_in_system_reminder_for_every_harness() {
507 for h in Harness::known() {
508 let out = adapter_for(h).render_plan_mode_context("BODY");
509 assert_eq!(out, "<system-reminder>\nBODY\n</system-reminder>");
510 assert_eq!(adapter_for(h).render_plan_mode_context(" "), "");
511 assert_eq!(
512 adapter_for(h).render_plan_mode_context("\n\n BODY \n\n"),
513 "<system-reminder>\nBODY\n</system-reminder>"
514 );
515 }
516 }
517
518 #[test]
519 fn run_capabilities_capture_run_option_support_by_harness() {
520 let claude = adapter_for(Harness::resolve("claude-code").unwrap()).run_capabilities();
521 assert!(claude.supports_guard);
522 assert!(claude.supports_bootstrap_with_no_stage);
523 assert!(claude.supports_stage_name_with_no_stage);
524
525 let codex = adapter_for(Harness::resolve("codex").unwrap()).run_capabilities();
526 assert!(codex.supports_guard);
527 assert!(codex.supports_bootstrap_with_no_stage);
528 assert!(!codex.supports_stage_name_with_no_stage);
529
530 let opencode = adapter_for(Harness::resolve("opencode").unwrap()).run_capabilities();
531 assert!(opencode.supports_guard);
532 assert!(opencode.supports_bootstrap_with_no_stage);
533 assert!(opencode.supports_stage_name_with_no_stage);
534 }
535
536 #[test]
537 fn guard_armed_message_is_harness_specific() {
538 // The post-arm `--guard` banner names the harness's native hook surface,
539 // so it lives behind the adapter rather than in generic run code.
540 let claude = adapter_for(Harness::resolve("claude-code").unwrap())
541 .guard_armed_message()
542 .expect("claude code has a write guard");
543 assert!(
544 claude.contains(".claude/settings.local.json"),
545 "claude banner names its hook file: {claude}"
546 );
547
548 let codex = adapter_for(Harness::resolve("codex").unwrap())
549 .guard_armed_message()
550 .expect("codex has a write guard");
551 assert!(
552 codex.contains(".codex/hooks.json"),
553 "codex banner names its hook file: {codex}"
554 );
555
556 let opencode = adapter_for(Harness::resolve("opencode").unwrap())
557 .guard_armed_message()
558 .expect("opencode has a write guard");
559 assert!(
560 opencode.contains(".opencode/plugins/slow-powers-eval-guard.js"),
561 "opencode banner names its plugin file: {opencode}"
562 );
563 }
564
565 #[test]
566 fn staged_slug_default_and_opencode_override_preserve_the_prefix() {
567 let prefix = "slow-powers-eval-";
568 assert_eq!(
569 adapter_for(Harness::resolve("claude-code").unwrap()).staged_slug(
570 prefix,
571 2,
572 "with_skill",
573 "my-skill"
574 ),
575 "slow-powers-eval-2-with_skill__my-skill"
576 );
577 assert_eq!(
578 adapter_for(Harness::resolve("opencode").unwrap()).staged_slug(
579 prefix,
580 2,
581 "with_skill",
582 "my-skill"
583 ),
584 "slow-powers-eval-2-with-skill-my-skill"
585 );
586 }
587}