Skip to main content

eval_magic/adapters/
harness.rs

1//! The harness adapter API — the single seam between generic run-mode code and
2//! harness-specific behavior.
3//!
4//! Every harness-specific concern hangs off the [`HarnessAdapter`] trait: how
5//! discoverable skills are presented in a dispatch prompt, how a persisted
6//! transcript is parsed, where staged skills live, and which native hook the
7//! write guard installs. Generic code resolves an adapter with [`adapter_for`]
8//! and then calls the trait — so [`adapter_for`] is the one place that names a
9//! concrete harness for this surface.
10
11use std::io;
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use crate::core::{AvailableSkill, Harness, ToolInvocation};
16
17use super::TranscriptSummary;
18use super::claude_cli::{
19    claude_exec_command_template, claude_judge_dispatch_recipe, claude_parallel_dispatch_recipe,
20};
21use super::codex_cli::{
22    codex_exec_command_template, codex_judge_dispatch_recipe, codex_parallel_dispatch_recipe,
23};
24use super::{
25    parse_claude_stream_json, parse_claude_stream_json_full, parse_codex_events,
26    parse_codex_events_full, parse_transcript, parse_transcript_full,
27    render_available_skills_block, render_codex_available_skills_block,
28    render_opencode_available_skills_block,
29};
30
31/// The behavior that varies by harness. Generic run-mode code depends on this
32/// trait, never on a concrete harness variant.
33pub trait HarnessAdapter {
34    /// The kebab-case identifier used in CLI flags, `dispatch.json`, and the
35    /// staged `conditions.json`.
36    fn label(&self) -> &'static str;
37
38    /// The project-local directory staged skills live under for this harness.
39    fn skills_dir(&self, repo_root: &Path) -> PathBuf;
40
41    /// Whether a staged skill's frontmatter `name:` is rewritten to its slug so
42    /// the harness's repo-local discovery resolves the staged copy.
43    fn rewrites_frontmatter_name(&self) -> bool;
44
45    /// Whether the skill-under-test is advertised in the available-skills block
46    /// under its staged slug (vs. its natural name). True for Codex, whose
47    /// repo-local discovery keys on the rewritten frontmatter name. (OpenCode
48    /// also rewrites the frontmatter to the slug yet still advertises the natural
49    /// name — a known inconsistency tracked for a separate fix.)
50    fn advertises_staged_slug_name(&self) -> bool;
51
52    /// Render the discoverable skills the way this harness natively surfaces
53    /// them (e.g. Claude Code's Skill-tool list, Codex's `## Skills`, OpenCode's
54    /// `<available_skills>` XML).
55    fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String;
56
57    /// How a staged skill is described as discoverable in the neutral
58    /// slug-disambiguation line (e.g. "via the Skill tool").
59    fn skill_surface_phrase(&self) -> &'static str;
60
61    /// The lead-in for the fallback "read the skill from `<path>`" instruction
62    /// when the staged identifier can't be resolved.
63    fn skill_unresolved_phrase(&self) -> &'static str;
64
65    /// The verbatim plan-mode procedure profile bundled for this harness.
66    fn plan_mode_profile(&self) -> &'static str;
67
68    /// Wrap a plan-mode profile as a `<system-reminder>` operating-context
69    /// layer. The default usually suffices.
70    fn render_plan_mode_context(&self, profile_text: &str) -> String {
71        let trimmed = profile_text.trim();
72        if trimmed.is_empty() {
73            return String::new();
74        }
75        format!("<system-reminder>\n{trimmed}\n</system-reminder>")
76    }
77
78    /// The **interactive** (agent-followed) `RUNBOOK.md` template a harness uses
79    /// under [`InSession`](crate::core::DispatchMechanism::InSession) dispatch,
80    /// carrying `{{TOKEN}}` placeholders the run fills. The default is the shared
81    /// headless template (harmless for the Cli-only harnesses that never read it
82    /// via this path); [`InSession`](crate::core::DispatchMechanism::InSession)
83    /// harnesses override it. The Cli-dispatch runbook always uses
84    /// [`HEADLESS_RUNBOOK_TEMPLATE`], selected by mechanism in `build_runbook`.
85    fn runbook_template(&self) -> &'static str {
86        HEADLESS_RUNBOOK_TEMPLATE
87    }
88
89    /// For a [`Cli`](crate::core::DispatchMechanism::Cli)-dispatch harness, the
90    /// filename (under a task's `outputs/` dir) its one-shot CLI writes the
91    /// transcript to. `None` when the harness dispatches in-session (no local
92    /// transcript) or has no Cli-mechanism transcript wired yet.
93    fn cli_events_filename(&self) -> Option<&'static str> {
94        None
95    }
96
97    /// For a [`Cli`](crate::core::DispatchMechanism::Cli)-dispatch harness, the
98    /// native model-selection flag accepted by the harness CLI. `None` means the
99    /// adapter has no model-selection support wired yet.
100    fn cli_model_flag(&self) -> Option<&'static str> {
101        None
102    }
103
104    /// The `Next:` guidance printed after `run` for a
105    /// [`Cli`](crate::core::DispatchMechanism::Cli)-dispatch harness: how to
106    /// dispatch each task through this harness's one-shot CLI and then ingest.
107    /// Empty for in-session harnesses (their guidance is the mechanism's, not the
108    /// adapter's).
109    fn cli_next_steps(&self, _ctx: CliDispatchContext<'_>) -> String {
110        String::new()
111    }
112
113    /// Extra `dispatch-manifest.md` lines describing this harness's Cli dispatch
114    /// recipe (command template, parallel recipe, ingest note). `None` when the
115    /// harness contributes no Cli-specific manifest section.
116    fn cli_manifest_section(&self, _ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
117        None
118    }
119
120    /// The post-`grade` / post-`ingest` judge dispatch guidance for a
121    /// [`Cli`](crate::core::DispatchMechanism::Cli)-dispatch harness. `None`
122    /// leaves the generic in-session-style judge handoff in place.
123    fn cli_judge_next_steps(&self, _ctx: CliJudgeContext) -> Option<String> {
124        None
125    }
126
127    /// Parse a persisted transcript into its ordered tool invocations.
128    fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>>;
129
130    /// Parse a persisted transcript into the full summary: tool invocations,
131    /// deduped token usage, duration, and final message text.
132    fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary>;
133
134    /// Parse a [`Cli`](crate::core::DispatchMechanism::Cli)-mechanism events file
135    /// (the harness CLI's captured output) into ordered tool invocations. Defaults
136    /// to [`parse_transcript`](Self::parse_transcript): for Codex/OpenCode the
137    /// on-disk parser already *is* the events parser, so the default is correct;
138    /// Claude Code overrides it, because its `parse_transcript` is the in-session
139    /// subagent parser while its Cli events are `claude -p` stream-json.
140    fn parse_cli_events(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
141        self.parse_transcript(path)
142    }
143
144    /// The full-summary counterpart of [`parse_cli_events`](Self::parse_cli_events).
145    fn parse_cli_events_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
146        self.parse_transcript_full(path)
147    }
148
149    /// Arm the write guard using this harness's native pre-tool hook surface,
150    /// returning the staged marker path. The guard's allowed roots are derived
151    /// from `stage_root` (the isolated env / agent cwd), so it bounds the agent to
152    /// the same env boundary that isolates its reads.
153    fn install_guard(
154        &self,
155        stage_root: &Path,
156        guard_exe: &Path,
157        ttl: Option<Duration>,
158    ) -> io::Result<PathBuf>;
159
160    /// The banner printed after `--guard` successfully arms, describing the
161    /// harness's native hook surface and how to remove it. Harness-specific text,
162    /// so it lives here rather than in generic run code. `None` for a harness with
163    /// no write guard (its [`install_guard`](Self::install_guard) errors), in which
164    /// case no banner is printed.
165    fn guard_armed_message(&self) -> Option<&'static str> {
166        None
167    }
168}
169
170/// The shared **headless** (human-followed) `RUNBOOK.md` template used by every
171/// [`Cli`](crate::core::DispatchMechanism::Cli)-dispatch run, regardless of
172/// harness (Codex, OpenCode, and Claude Code in hybrid/headless).
173pub const HEADLESS_RUNBOOK_TEMPLATE: &str =
174    include_str!("../../profiles/shared/runbook-headless.md");
175
176pub struct ClaudeCodeAdapter;
177pub struct CodexAdapter;
178pub struct OpenCodeAdapter;
179
180/// Context for rendering a harness's one-shot CLI agent-dispatch guidance.
181#[derive(Debug, Clone, Copy)]
182pub struct CliDispatchContext<'a> {
183    pub guard: bool,
184    pub target_args: &'a str,
185    pub iteration: u32,
186    pub agent_model: Option<&'a str>,
187}
188
189/// Context for rendering a harness's `dispatch-manifest.md` CLI recipe.
190#[derive(Debug, Clone, Copy)]
191pub struct CliManifestContext<'a> {
192    pub guard: bool,
193    pub agent_model: Option<&'a str>,
194}
195
196/// Context for rendering a harness's one-shot CLI judge-dispatch guidance.
197#[derive(Debug, Clone, Copy)]
198pub struct CliJudgeContext {
199    pub guard: bool,
200}
201
202impl HarnessAdapter for ClaudeCodeAdapter {
203    fn label(&self) -> &'static str {
204        "claude-code"
205    }
206    fn skills_dir(&self, repo_root: &Path) -> PathBuf {
207        repo_root.join(".claude").join("skills")
208    }
209    fn rewrites_frontmatter_name(&self) -> bool {
210        false
211    }
212    fn advertises_staged_slug_name(&self) -> bool {
213        false
214    }
215    fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
216        render_available_skills_block(skills)
217    }
218    fn skill_surface_phrase(&self) -> &'static str {
219        "via the Skill tool"
220    }
221    fn skill_unresolved_phrase(&self) -> &'static str {
222        "If the Skill tool cannot resolve that identifier"
223    }
224    fn plan_mode_profile(&self) -> &'static str {
225        include_str!("../../profiles/claude-code/plan-mode.md")
226    }
227    fn runbook_template(&self) -> &'static str {
228        include_str!("../../profiles/claude-code/runbook.md")
229    }
230    fn cli_events_filename(&self) -> Option<&'static str> {
231        Some("claude-events.jsonl")
232    }
233    fn cli_model_flag(&self) -> Option<&'static str> {
234        Some("--model")
235    }
236    fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
237        format!(
238            "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task (from the env dir — `claude` has no --cd flag) with:\n{}\nThen run `ingest{target_args} --iteration {iteration} --harness claude-code`.",
239            claude_exec_command_template(self.cli_model_flag(), ctx.agent_model),
240            target_args = ctx.target_args,
241            iteration = ctx.iteration
242        )
243    }
244    fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
245        Some(vec![
246            "After all dispatches (Claude Code hybrid):".to_string(),
247            String::new(),
248            "Run one fresh `claude -p` per task from the env dir (`cd <eval-root>` — `claude` has no --cd flag). `--output-format stream-json` requires `--verbose`; detach stdin with `</dev/null` so a permission prompt cannot block and piped task data cannot become extra prompt context; capture stdout as `outputs/claude-events.jsonl` and stderr as `outputs/claude-stderr.log`.".to_string(),
249            String::new(),
250            "```bash".to_string(),
251            claude_exec_command_template(self.cli_model_flag(), ctx.agent_model),
252            "```".to_string(),
253            String::new(),
254            "Parallel dispatch from this iteration directory:".to_string(),
255            String::new(),
256            "```bash".to_string(),
257            claude_parallel_dispatch_recipe(self.cli_model_flag(), ctx.agent_model),
258            "```".to_string(),
259            String::new(),
260            "Then run `eval-magic ingest --harness claude-code --run-mode hybrid`; Claude hybrid ingest reads each task's `outputs/claude-events.jsonl`.".to_string(),
261            String::new(),
262        ])
263    }
264    fn cli_judge_next_steps(&self, _ctx: CliJudgeContext) -> Option<String> {
265        Some(claude_judge_dispatch_recipe(self.cli_model_flag()))
266    }
267    fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
268        parse_transcript(path)
269    }
270    fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
271        parse_transcript_full(path)
272    }
273    fn parse_cli_events(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
274        parse_claude_stream_json(path)
275    }
276    fn parse_cli_events_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
277        parse_claude_stream_json_full(path)
278    }
279    fn install_guard(
280        &self,
281        stage_root: &Path,
282        guard_exe: &Path,
283        ttl: Option<Duration>,
284    ) -> io::Result<PathBuf> {
285        crate::sandbox::install::install_claude_guard(stage_root, guard_exe, ttl)
286    }
287    fn guard_armed_message(&self) -> Option<&'static str> {
288        Some(
289            "\n🛡 Write guard armed: a PreToolUse hook is staged in .claude/settings.local.json\n   and will block writes/installs outside the eval sandbox during dispatches —\n   both in-session subagents and `claude -p` (hybrid/headless), which loads the\n   hook from the env cwd each dispatch runs in.\n   It auto-expires in 6h and is removed on the next run; to remove it now:\n     eval-magic teardown-guard",
290        )
291    }
292}
293
294impl HarnessAdapter for CodexAdapter {
295    fn label(&self) -> &'static str {
296        "codex"
297    }
298    fn skills_dir(&self, repo_root: &Path) -> PathBuf {
299        repo_root.join(".agents").join("skills")
300    }
301    fn rewrites_frontmatter_name(&self) -> bool {
302        true
303    }
304    fn advertises_staged_slug_name(&self) -> bool {
305        true
306    }
307    fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
308        render_codex_available_skills_block(skills)
309    }
310    fn skill_surface_phrase(&self) -> &'static str {
311        "as a Codex skill"
312    }
313    fn skill_unresolved_phrase(&self) -> &'static str {
314        "If it does not load as a Codex skill"
315    }
316    fn plan_mode_profile(&self) -> &'static str {
317        include_str!("../../profiles/codex/plan-mode.md")
318    }
319    fn cli_events_filename(&self) -> Option<&'static str> {
320        Some("codex-events.jsonl")
321    }
322    fn cli_model_flag(&self) -> Option<&'static str> {
323        Some("-m")
324    }
325    fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
326        format!(
327            "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task with:\n{}\nThen run `ingest{target_args} --iteration {iteration} --harness codex`.",
328            codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
329            target_args = ctx.target_args,
330            iteration = ctx.iteration
331        )
332    }
333    fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
334        Some(vec![
335            "After all dispatches (Codex):".to_string(),
336            String::new(),
337            "Run one fresh `codex --ask-for-approval never exec --json` per task. Detach stdin with `</dev/null` so piped task data cannot become extra prompt context; capture stdout as `outputs/codex-events.jsonl` and stderr as `outputs/codex-stderr.log`.".to_string(),
338            String::new(),
339            "```bash".to_string(),
340            codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
341            "```".to_string(),
342            String::new(),
343            "Parallel dispatch from this iteration directory:".to_string(),
344            String::new(),
345            "```bash".to_string(),
346            codex_parallel_dispatch_recipe(self.cli_model_flag(), ctx.guard, ctx.agent_model),
347            "```".to_string(),
348            String::new(),
349            "Then run `eval-magic ingest --harness codex`; Codex transcript ingest reads each task's `outputs/codex-events.jsonl`.".to_string(),
350            String::new(),
351        ])
352    }
353    fn cli_judge_next_steps(&self, ctx: CliJudgeContext) -> Option<String> {
354        Some(codex_judge_dispatch_recipe(
355            self.cli_model_flag(),
356            ctx.guard,
357        ))
358    }
359    fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
360        parse_codex_events(path)
361    }
362    fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
363        parse_codex_events_full(path)
364    }
365    fn install_guard(
366        &self,
367        stage_root: &Path,
368        guard_exe: &Path,
369        ttl: Option<Duration>,
370    ) -> io::Result<PathBuf> {
371        crate::sandbox::install::install_codex_guard(stage_root, guard_exe, ttl)
372    }
373    fn guard_armed_message(&self) -> Option<&'static str> {
374        Some(
375            "\n🛡 Write guard armed: a PreToolUse hook is staged in .codex/hooks.json\n   and will block writes/installs outside the eval sandbox during Codex dispatches.\n   Dispatch with codex --ask-for-approval never exec --dangerously-bypass-hook-trust so the vetted eval hook runs.\n   It auto-expires in 6h and is removed on the next run; to remove it now:\n     eval-magic teardown-guard",
376        )
377    }
378}
379
380impl HarnessAdapter for OpenCodeAdapter {
381    fn label(&self) -> &'static str {
382        "opencode"
383    }
384    fn skills_dir(&self, repo_root: &Path) -> PathBuf {
385        repo_root.join(".opencode").join("skills")
386    }
387    fn rewrites_frontmatter_name(&self) -> bool {
388        true
389    }
390    fn advertises_staged_slug_name(&self) -> bool {
391        false
392    }
393    fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
394        render_opencode_available_skills_block(skills)
395    }
396    fn skill_surface_phrase(&self) -> &'static str {
397        "as an OpenCode skill"
398    }
399    fn skill_unresolved_phrase(&self) -> &'static str {
400        "If it does not load as an OpenCode skill"
401    }
402    fn plan_mode_profile(&self) -> &'static str {
403        include_str!("../../profiles/opencode/plan-mode.md")
404    }
405    fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
406        let model_note = if ctx.agent_model.is_some() {
407            " Model selection was recorded as provenance, but the OpenCode adapter has no CLI model flag wired yet."
408        } else {
409            ""
410        };
411        format!(
412            "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task with `opencode run`.{model_note} OpenCode transcript ingest is not yet wired, so assemble each task's `run.json`/`timing.json` manually (or capture `opencode run --format json` / `opencode export` output), then run `ingest{target_args} --iteration {iteration} --harness opencode`.",
413            target_args = ctx.target_args,
414            iteration = ctx.iteration
415        )
416    }
417    // OpenCode transcript ingest is not yet wired. In the current dispatch flow
418    // this is unreachable (no subagents dir and no events file), so delegating to
419    // the shared JSONL parser preserves the pre-refactor behavior of the
420    // transcript-source branch until OpenCode ingest lands.
421    fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
422        parse_transcript(path)
423    }
424    fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
425        parse_transcript_full(path)
426    }
427    fn install_guard(
428        &self,
429        _stage_root: &Path,
430        _guard_exe: &Path,
431        _ttl: Option<Duration>,
432    ) -> io::Result<PathBuf> {
433        Err(io::Error::new(
434            io::ErrorKind::Unsupported,
435            "--guard is not yet supported for the opencode harness",
436        ))
437    }
438}
439
440/// Resolve the adapter for a [`Harness`]. This is the single dispatch point on
441/// the harness variant for all harness-specific behavior; every other module
442/// goes through the returned trait object.
443pub fn adapter_for(harness: Harness) -> &'static dyn HarnessAdapter {
444    match harness {
445        Harness::ClaudeCode => &ClaudeCodeAdapter,
446        Harness::Codex => &CodexAdapter,
447        Harness::OpenCode => &OpenCodeAdapter,
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn labels_match_kebab_case_identifiers() {
457        assert_eq!(adapter_for(Harness::ClaudeCode).label(), "claude-code");
458        assert_eq!(adapter_for(Harness::Codex).label(), "codex");
459        assert_eq!(adapter_for(Harness::OpenCode).label(), "opencode");
460    }
461
462    #[test]
463    fn skills_dir_is_harness_native() {
464        let root = Path::new("/repo");
465        assert_eq!(
466            adapter_for(Harness::ClaudeCode).skills_dir(root),
467            root.join(".claude").join("skills")
468        );
469        assert_eq!(
470            adapter_for(Harness::Codex).skills_dir(root),
471            root.join(".agents").join("skills")
472        );
473        assert_eq!(
474            adapter_for(Harness::OpenCode).skills_dir(root),
475            root.join(".opencode").join("skills")
476        );
477    }
478
479    #[test]
480    fn only_codex_and_opencode_rewrite_frontmatter() {
481        assert!(!adapter_for(Harness::ClaudeCode).rewrites_frontmatter_name());
482        assert!(adapter_for(Harness::Codex).rewrites_frontmatter_name());
483        assert!(adapter_for(Harness::OpenCode).rewrites_frontmatter_name());
484    }
485
486    #[test]
487    fn plan_mode_context_wraps_in_system_reminder_for_every_harness() {
488        for h in [Harness::ClaudeCode, Harness::Codex, Harness::OpenCode] {
489            let out = adapter_for(h).render_plan_mode_context("BODY");
490            assert_eq!(out, "<system-reminder>\nBODY\n</system-reminder>");
491            assert_eq!(adapter_for(h).render_plan_mode_context("   "), "");
492        }
493    }
494
495    #[test]
496    fn claude_adapter_advertises_cli_events_file_and_model_flag() {
497        let a = adapter_for(Harness::ClaudeCode);
498        assert_eq!(a.cli_events_filename(), Some("claude-events.jsonl"));
499        assert_eq!(a.cli_model_flag(), Some("--model"));
500    }
501
502    #[test]
503    fn guard_armed_message_is_harness_specific_and_absent_for_opencode() {
504        // The post-arm `--guard` banner names the harness's native hook surface,
505        // so it lives behind the adapter rather than in generic run code.
506        let claude = adapter_for(Harness::ClaudeCode)
507            .guard_armed_message()
508            .expect("claude code has a write guard");
509        assert!(
510            claude.contains(".claude/settings.local.json"),
511            "claude banner names its hook file: {claude}"
512        );
513
514        let codex = adapter_for(Harness::Codex)
515            .guard_armed_message()
516            .expect("codex has a write guard");
517        assert!(
518            codex.contains(".codex/hooks.json"),
519            "codex banner names its hook file: {codex}"
520        );
521
522        // OpenCode has no write guard (its install_guard errors), so there is no
523        // banner to print.
524        assert_eq!(adapter_for(Harness::OpenCode).guard_armed_message(), None);
525    }
526
527    #[test]
528    fn claude_parse_cli_events_full_reads_stream_json_result_event() {
529        use serde_json::json;
530        let dir = tempfile::TempDir::new().unwrap();
531        let path = dir.path().join("claude-events.jsonl");
532        // No per-line timestamps; the result event is the only source of duration.
533        let lines = [
534            json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [
535                {"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "ls"}}
536            ]}}),
537            json!({"type": "result", "subtype": "success", "is_error": false, "result": "Done", "duration_ms": 5637, "usage": {"input_tokens": 1, "output_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}),
538        ];
539        let body = lines
540            .iter()
541            .map(|l| l.to_string())
542            .collect::<Vec<_>>()
543            .join("\n");
544        std::fs::write(&path, format!("{body}\n")).unwrap();
545
546        let a = adapter_for(Harness::ClaudeCode);
547        let summary = a.parse_cli_events_full(&path).unwrap();
548        assert_eq!(summary.final_text, Some("Done".into()));
549        assert_eq!(summary.duration_ms, Some(5637));
550        assert_eq!(summary.tool_invocations.len(), 1);
551        assert_eq!(summary.tool_invocations[0].name, "Bash");
552
553        // The on-disk parser would find no duration here (no line timestamps),
554        // proving parse_cli_events_full routes to the stream-json parser.
555        assert_eq!(a.parse_transcript_full(&path).unwrap().duration_ms, None);
556    }
557
558    #[test]
559    fn codex_parse_cli_events_delegates_to_events_parser() {
560        use serde_json::json;
561        let dir = tempfile::TempDir::new().unwrap();
562        let path = dir.path().join("codex-events.jsonl");
563        let line = json!({"type": "item.completed", "item": {"id": "i1", "type": "command_execution", "command": "bun test", "output": "ok"}});
564        std::fs::write(&path, format!("{line}\n")).unwrap();
565
566        let inv = adapter_for(Harness::Codex).parse_cli_events(&path).unwrap();
567        assert_eq!(inv.len(), 1);
568        assert_eq!(inv[0].name, "command_execution");
569    }
570}