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<'a> {
199    pub guard: bool,
200    pub iteration_dir: &'a Path,
201}
202
203impl HarnessAdapter for ClaudeCodeAdapter {
204    fn label(&self) -> &'static str {
205        "claude-code"
206    }
207    fn skills_dir(&self, repo_root: &Path) -> PathBuf {
208        repo_root.join(".claude").join("skills")
209    }
210    fn rewrites_frontmatter_name(&self) -> bool {
211        false
212    }
213    fn advertises_staged_slug_name(&self) -> bool {
214        false
215    }
216    fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
217        render_available_skills_block(skills)
218    }
219    fn skill_surface_phrase(&self) -> &'static str {
220        "via the Skill tool"
221    }
222    fn skill_unresolved_phrase(&self) -> &'static str {
223        "If the Skill tool cannot resolve that identifier"
224    }
225    fn plan_mode_profile(&self) -> &'static str {
226        include_str!("../../profiles/claude-code/plan-mode.md")
227    }
228    fn runbook_template(&self) -> &'static str {
229        include_str!("../../profiles/claude-code/runbook.md")
230    }
231    fn cli_events_filename(&self) -> Option<&'static str> {
232        Some("claude-events.jsonl")
233    }
234    fn cli_model_flag(&self) -> Option<&'static str> {
235        Some("--model")
236    }
237    fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
238        format!(
239            "\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`.",
240            claude_exec_command_template(self.cli_model_flag(), ctx.agent_model),
241            target_args = ctx.target_args,
242            iteration = ctx.iteration
243        )
244    }
245    fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
246        Some(vec![
247            "After all dispatches (Claude Code hybrid):".to_string(),
248            String::new(),
249            "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(),
250            String::new(),
251            "```bash".to_string(),
252            claude_exec_command_template(self.cli_model_flag(), ctx.agent_model),
253            "```".to_string(),
254            String::new(),
255            "Parallel dispatch from this iteration directory:".to_string(),
256            String::new(),
257            "```bash".to_string(),
258            claude_parallel_dispatch_recipe(self.cli_model_flag(), ctx.agent_model),
259            "```".to_string(),
260            String::new(),
261            "Then run `eval-magic ingest --harness claude-code --run-mode hybrid`; Claude hybrid ingest reads each task's `outputs/claude-events.jsonl`.".to_string(),
262            String::new(),
263        ])
264    }
265    fn cli_judge_next_steps(&self, ctx: CliJudgeContext<'_>) -> Option<String> {
266        Some(claude_judge_dispatch_recipe(
267            self.cli_model_flag(),
268            ctx.iteration_dir,
269        ))
270    }
271    fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
272        parse_transcript(path)
273    }
274    fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
275        parse_transcript_full(path)
276    }
277    fn parse_cli_events(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
278        parse_claude_stream_json(path)
279    }
280    fn parse_cli_events_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
281        parse_claude_stream_json_full(path)
282    }
283    fn install_guard(
284        &self,
285        stage_root: &Path,
286        guard_exe: &Path,
287        ttl: Option<Duration>,
288    ) -> io::Result<PathBuf> {
289        crate::sandbox::install::install_claude_guard(stage_root, guard_exe, ttl)
290    }
291    fn guard_armed_message(&self) -> Option<&'static str> {
292        Some(
293            "\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",
294        )
295    }
296}
297
298impl HarnessAdapter for CodexAdapter {
299    fn label(&self) -> &'static str {
300        "codex"
301    }
302    fn skills_dir(&self, repo_root: &Path) -> PathBuf {
303        repo_root.join(".agents").join("skills")
304    }
305    fn rewrites_frontmatter_name(&self) -> bool {
306        true
307    }
308    fn advertises_staged_slug_name(&self) -> bool {
309        true
310    }
311    fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
312        render_codex_available_skills_block(skills)
313    }
314    fn skill_surface_phrase(&self) -> &'static str {
315        "as a Codex skill"
316    }
317    fn skill_unresolved_phrase(&self) -> &'static str {
318        "If it does not load as a Codex skill"
319    }
320    fn plan_mode_profile(&self) -> &'static str {
321        include_str!("../../profiles/codex/plan-mode.md")
322    }
323    fn cli_events_filename(&self) -> Option<&'static str> {
324        Some("codex-events.jsonl")
325    }
326    fn cli_model_flag(&self) -> Option<&'static str> {
327        Some("-m")
328    }
329    fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
330        format!(
331            "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task with:\n{}\nThen run `ingest{target_args} --iteration {iteration} --harness codex`.",
332            codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
333            target_args = ctx.target_args,
334            iteration = ctx.iteration
335        )
336    }
337    fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
338        Some(vec![
339            "After all dispatches (Codex):".to_string(),
340            String::new(),
341            "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(),
342            String::new(),
343            "```bash".to_string(),
344            codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
345            "```".to_string(),
346            String::new(),
347            "Parallel dispatch from this iteration directory:".to_string(),
348            String::new(),
349            "```bash".to_string(),
350            codex_parallel_dispatch_recipe(self.cli_model_flag(), ctx.guard, ctx.agent_model),
351            "```".to_string(),
352            String::new(),
353            "Then run `eval-magic ingest --harness codex`; Codex transcript ingest reads each task's `outputs/codex-events.jsonl`.".to_string(),
354            String::new(),
355        ])
356    }
357    fn cli_judge_next_steps(&self, ctx: CliJudgeContext<'_>) -> Option<String> {
358        Some(codex_judge_dispatch_recipe(
359            self.cli_model_flag(),
360            ctx.guard,
361            ctx.iteration_dir,
362        ))
363    }
364    fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
365        parse_codex_events(path)
366    }
367    fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
368        parse_codex_events_full(path)
369    }
370    fn install_guard(
371        &self,
372        stage_root: &Path,
373        guard_exe: &Path,
374        ttl: Option<Duration>,
375    ) -> io::Result<PathBuf> {
376        crate::sandbox::install::install_codex_guard(stage_root, guard_exe, ttl)
377    }
378    fn guard_armed_message(&self) -> Option<&'static str> {
379        Some(
380            "\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",
381        )
382    }
383}
384
385impl HarnessAdapter for OpenCodeAdapter {
386    fn label(&self) -> &'static str {
387        "opencode"
388    }
389    fn skills_dir(&self, repo_root: &Path) -> PathBuf {
390        repo_root.join(".opencode").join("skills")
391    }
392    fn rewrites_frontmatter_name(&self) -> bool {
393        true
394    }
395    fn advertises_staged_slug_name(&self) -> bool {
396        false
397    }
398    fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
399        render_opencode_available_skills_block(skills)
400    }
401    fn skill_surface_phrase(&self) -> &'static str {
402        "as an OpenCode skill"
403    }
404    fn skill_unresolved_phrase(&self) -> &'static str {
405        "If it does not load as an OpenCode skill"
406    }
407    fn plan_mode_profile(&self) -> &'static str {
408        include_str!("../../profiles/opencode/plan-mode.md")
409    }
410    fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
411        let model_note = if ctx.agent_model.is_some() {
412            " Model selection was recorded as provenance, but the OpenCode adapter has no CLI model flag wired yet."
413        } else {
414            ""
415        };
416        format!(
417            "\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`.",
418            target_args = ctx.target_args,
419            iteration = ctx.iteration
420        )
421    }
422    // OpenCode transcript ingest is not yet wired. In the current dispatch flow
423    // this is unreachable (no subagents dir and no events file), so delegating to
424    // the shared JSONL parser preserves the pre-refactor behavior of the
425    // transcript-source branch until OpenCode ingest lands.
426    fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
427        parse_transcript(path)
428    }
429    fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
430        parse_transcript_full(path)
431    }
432    fn install_guard(
433        &self,
434        _stage_root: &Path,
435        _guard_exe: &Path,
436        _ttl: Option<Duration>,
437    ) -> io::Result<PathBuf> {
438        Err(io::Error::new(
439            io::ErrorKind::Unsupported,
440            "--guard is not yet supported for the opencode harness",
441        ))
442    }
443}
444
445/// Resolve the adapter for a [`Harness`]. This is the single dispatch point on
446/// the harness variant for all harness-specific behavior; every other module
447/// goes through the returned trait object.
448pub fn adapter_for(harness: Harness) -> &'static dyn HarnessAdapter {
449    match harness {
450        Harness::ClaudeCode => &ClaudeCodeAdapter,
451        Harness::Codex => &CodexAdapter,
452        Harness::OpenCode => &OpenCodeAdapter,
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn labels_match_kebab_case_identifiers() {
462        assert_eq!(adapter_for(Harness::ClaudeCode).label(), "claude-code");
463        assert_eq!(adapter_for(Harness::Codex).label(), "codex");
464        assert_eq!(adapter_for(Harness::OpenCode).label(), "opencode");
465    }
466
467    #[test]
468    fn skills_dir_is_harness_native() {
469        let root = Path::new("/repo");
470        assert_eq!(
471            adapter_for(Harness::ClaudeCode).skills_dir(root),
472            root.join(".claude").join("skills")
473        );
474        assert_eq!(
475            adapter_for(Harness::Codex).skills_dir(root),
476            root.join(".agents").join("skills")
477        );
478        assert_eq!(
479            adapter_for(Harness::OpenCode).skills_dir(root),
480            root.join(".opencode").join("skills")
481        );
482    }
483
484    #[test]
485    fn only_codex_and_opencode_rewrite_frontmatter() {
486        assert!(!adapter_for(Harness::ClaudeCode).rewrites_frontmatter_name());
487        assert!(adapter_for(Harness::Codex).rewrites_frontmatter_name());
488        assert!(adapter_for(Harness::OpenCode).rewrites_frontmatter_name());
489    }
490
491    #[test]
492    fn plan_mode_context_wraps_in_system_reminder_for_every_harness() {
493        for h in [Harness::ClaudeCode, Harness::Codex, Harness::OpenCode] {
494            let out = adapter_for(h).render_plan_mode_context("BODY");
495            assert_eq!(out, "<system-reminder>\nBODY\n</system-reminder>");
496            assert_eq!(adapter_for(h).render_plan_mode_context("   "), "");
497        }
498    }
499
500    #[test]
501    fn claude_adapter_advertises_cli_events_file_and_model_flag() {
502        let a = adapter_for(Harness::ClaudeCode);
503        assert_eq!(a.cli_events_filename(), Some("claude-events.jsonl"));
504        assert_eq!(a.cli_model_flag(), Some("--model"));
505    }
506
507    #[test]
508    fn guard_armed_message_is_harness_specific_and_absent_for_opencode() {
509        // The post-arm `--guard` banner names the harness's native hook surface,
510        // so it lives behind the adapter rather than in generic run code.
511        let claude = adapter_for(Harness::ClaudeCode)
512            .guard_armed_message()
513            .expect("claude code has a write guard");
514        assert!(
515            claude.contains(".claude/settings.local.json"),
516            "claude banner names its hook file: {claude}"
517        );
518
519        let codex = adapter_for(Harness::Codex)
520            .guard_armed_message()
521            .expect("codex has a write guard");
522        assert!(
523            codex.contains(".codex/hooks.json"),
524            "codex banner names its hook file: {codex}"
525        );
526
527        // OpenCode has no write guard (its install_guard errors), so there is no
528        // banner to print.
529        assert_eq!(adapter_for(Harness::OpenCode).guard_armed_message(), None);
530    }
531
532    #[test]
533    fn claude_parse_cli_events_full_reads_stream_json_result_event() {
534        use serde_json::json;
535        let dir = tempfile::TempDir::new().unwrap();
536        let path = dir.path().join("claude-events.jsonl");
537        // No per-line timestamps; the result event is the only source of duration.
538        let lines = [
539            json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [
540                {"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "ls"}}
541            ]}}),
542            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}}),
543        ];
544        let body = lines
545            .iter()
546            .map(|l| l.to_string())
547            .collect::<Vec<_>>()
548            .join("\n");
549        std::fs::write(&path, format!("{body}\n")).unwrap();
550
551        let a = adapter_for(Harness::ClaudeCode);
552        let summary = a.parse_cli_events_full(&path).unwrap();
553        assert_eq!(summary.final_text, Some("Done".into()));
554        assert_eq!(summary.duration_ms, Some(5637));
555        assert_eq!(summary.tool_invocations.len(), 1);
556        assert_eq!(summary.tool_invocations[0].name, "Bash");
557
558        // The on-disk parser would find no duration here (no line timestamps),
559        // proving parse_cli_events_full routes to the stream-json parser.
560        assert_eq!(a.parse_transcript_full(&path).unwrap().duration_ms, None);
561    }
562
563    #[test]
564    fn codex_parse_cli_events_delegates_to_events_parser() {
565        use serde_json::json;
566        let dir = tempfile::TempDir::new().unwrap();
567        let path = dir.path().join("codex-events.jsonl");
568        let line = json!({"type": "item.completed", "item": {"id": "i1", "type": "command_execution", "command": "bun test", "output": "ok"}});
569        std::fs::write(&path, format!("{line}\n")).unwrap();
570
571        let inv = adapter_for(Harness::Codex).parse_cli_events(&path).unwrap();
572        assert_eq!(inv.len(), 1);
573        assert_eq!(inv[0].name, "command_execution");
574    }
575}