Skip to main content

devflow_core/
state.rs

1//! DevFlow state machine.
2//!
3//! Drives the development workflow through a single linear chain of five stages:
4//! Define → Plan → Code → Validate → Ship. See [`crate::stage::Stage`].
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::path::PathBuf;
9use std::str::FromStr;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use crate::mode::Mode;
13use crate::phase_id::PhaseId;
14use crate::stage::Stage;
15
16/// Full workflow state persisted to `.devflow/state.json`.
17///
18/// # Construction
19///
20/// Marked `#[non_exhaustive]`: downstream crates must build this through
21/// [`State::new`] and then assign the fields they care about, rather than by
22/// struct literal. Deserialization is unaffected — the `Deserialize` derive
23/// and every `#[serde(default)]` field keep working exactly as before, so
24/// state files written by older binaries still load.
25///
26/// This exists because `State` accumulates a field roughly every phase that
27/// adds a run-scoped concept (`worktree_path`, `monitor_pid`, `stop_until`,
28/// `yes_ship`, and — in phase 28 — `session_id` and `checkpoint_resumes`).
29/// Without `non_exhaustive`, each of those additions is a semver-breaking
30/// change for any consumer that used a struct literal, which would force a
31/// major bump for what is really an internal bookkeeping change. Paying that
32/// cost once here makes every future field additive.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[non_exhaustive]
35pub struct State {
36    /// Current workflow stage.
37    pub stage: Stage,
38    /// Phase number being worked on.
39    pub phase: PhaseId,
40    /// Which coding agent was launched.
41    pub agent: AgentKind,
42    /// How the pipeline is driven (auto vs. supervise).
43    pub mode: Mode,
44    /// Whether a gate has been written and is awaiting a human response.
45    #[serde(default)]
46    pub gate_pending: bool,
47    /// Consecutive Validate failures — drives the Auto-mode forced gate after
48    /// [`crate::mode::MAX_CONSECUTIVE_FAILURES`] failures. Persisted across
49    /// `devflow advance` invocations so the counter survives monitor restarts.
50    #[serde(default)]
51    pub consecutive_failures: u32,
52    /// Consecutive infrastructure-class faults (`ResourceKilled`,
53    /// `AgentUnavailable`) — distinct from [`Self::consecutive_failures`]
54    /// (D-08, 17-01). Gates at [`crate::mode::MAX_INFRA_FAILURES`]. Any
55    /// increment (wired in Plan 04) must use `saturating_add` so a
56    /// long-running stuck loop cannot overflow `u32`. A serde-absent value
57    /// (older persisted state) defaults to 0. Reset to 0 on every successful
58    /// stage transition, alongside `consecutive_failures` (CR-01, 17-06 gap
59    /// closure), so the ceiling bounds a stuck loop, not a phase's lifetime.
60    #[serde(default)]
61    pub infra_failures: u32,
62    /// How many times a preflight gate has been resolved and retried for
63    /// this phase (18f). Bounded by [`crate::mode::MAX_PREFLIGHT_RETRIES`].
64    /// Persisted rather than recursion-scoped because the documented wedge
65    /// spanned separate `devflow` invocations after a monitor death — an
66    /// in-process recursion-depth counter would reset to zero on every new
67    /// process and fail to bound the exact incident it exists to prevent.
68    /// Reset to 0 whenever preflight passes and whenever a human explicitly
69    /// approves (`GateAction::Advance`), both inside `run_preflight`. Unlike
70    /// [`Self::consecutive_failures`] and [`Self::infra_failures`], this
71    /// counter is NOT touched by `transition()`.
72    #[serde(default)]
73    pub preflight_retries: u32,
74    /// The commit count observed on the phase's feature branch at the most
75    /// recent Validate failure (999.66, D-03) — the forward-progress
76    /// baseline [`crate::mode::consecutive_failures_made_progress`] compares
77    /// against to decide whether a new failure begins a fresh streak or
78    /// continues the existing one.
79    ///
80    /// `None` means no prior failure has been recorded — either the first
81    /// failure of a phase, or the first failure observed after resuming
82    /// state written by a binary predating this field — and is deliberately
83    /// distinct from `Some(0)`, which means a failure WAS recorded and the
84    /// branch genuinely carried zero commits at that moment; a later failure
85    /// that again counts zero commits must accumulate against that `Some(0)`
86    /// baseline rather than being treated as a fresh streak.
87    ///
88    /// A serde-absent value (state written by a binary predating this field)
89    /// deserializes to `None`, which is exactly the "no prior record"
90    /// meaning above — the same backward-compat pattern as every other
91    /// `#[serde(default)]` field added since 17-01.
92    ///
93    /// Unlike [`Self::consecutive_failures`] and [`Self::infra_failures`],
94    /// this field is NOT touched by `transition()` — it is a baseline
95    /// observation rather than a counter, matching how
96    /// [`Self::preflight_retries`] and [`Self::checkpoint_resumes`] are
97    /// handled. It is replaced wholesale at each failure rather than
98    /// incremented, so it needs no `saturating_add` treatment, unlike every
99    /// other numeric field on this struct.
100    #[serde(default)]
101    pub last_validate_failure_commit_count: Option<u32>,
102    /// Every Validate failure recorded for this PHASE, accumulated without
103    /// regard to forward progress (999.78/WR-01, D-07) — the backstop bound
104    /// [`crate::mode::MAX_PHASE_VALIDATE_FAILURES`] compares against, and the
105    /// leading number in the Supervise gate message (WR-04).
106    ///
107    /// A serde-absent value (state written by a binary predating this field)
108    /// deserializes to 0, which is exactly its "no failures recorded for this
109    /// phase" meaning — the same backward-compat pattern as every other
110    /// `#[serde(default)]` field added since 17-01. Unlike
111    /// [`Self::last_validate_failure_commit_count`], zero is not ambiguous
112    /// here: an upgraded binary and a genuine first failure both start the
113    /// budget at its full width, and that widening is what IN-02's distinct
114    /// loop-back reason exists to announce.
115    ///
116    /// Why it exists next to [`Self::consecutive_failures`] rather than
117    /// replacing it: `consecutive_failures` is reset whenever
118    /// [`crate::mode::consecutive_failures_made_progress`] reports that new
119    /// commits landed, and the Code stage's fix command is a GSD command
120    /// which routinely commits `.planning/` artifacts even when no source
121    /// changed. A loop that commits something trivial every cycle therefore
122    /// resets the streak every cycle and never reaches
123    /// [`crate::mode::MAX_CONSECUTIVE_FAILURES`]. This total cannot be reset
124    /// by a commit count.
125    ///
126    /// **Lifetime — deliberately unlike every other counter on this struct.**
127    /// It is NOT touched by the stage transition (`transition_resets_*` has no
128    /// say over it), matching how [`Self::preflight_retries`] and
129    /// [`Self::checkpoint_resumes`] are handled, because it is a per-phase
130    /// total rather than a per-streak counter. It is also carried across a
131    /// forced restart: `commands::start()` reads any persisted state for the
132    /// same phase and copies this one field into the fresh `State`, because a
133    /// bound a `devflow start --force` resets does not bound the unattended
134    /// case D-07 exists for. Exactly two events reset it to zero:
135    ///
136    /// 1. **Phase completion** — `finish_workflow_with_gate_timeout` calls
137    ///    `workflow::clear_state`, deleting `.devflow/state-{NN}.json`, so the
138    ///    next start for that phase finds nothing to carry.
139    /// 2. **Operator approval at the ceiling gate** — the Validate gate
140    ///    handling zeroes it when a human advances or loops back AND
141    ///    [`crate::mode::phase_failure_ceiling_reached`] is true. Keyed on that
142    ///    predicate and never on "a gate fired": Supervise gates on every
143    ///    Validate, so a gate-keyed reset would clear the total at every
144    ///    failure and it would never accumulate in the one mode where an
145    ///    operator watches every occurrence.
146    ///
147    /// Any increment must use `saturating_add`, like [`Self::infra_failures`]
148    /// and [`Self::checkpoint_resumes`], so an exhausted budget can never wrap
149    /// back to zero and silently restore itself.
150    #[serde(default)]
151    pub phase_validate_failures: u32,
152    /// The content fingerprint of this phase's `{N}-VERIFICATION.md` as it
153    /// stood at the START of this run (999.79), read via
154    /// [`crate::agent_result::phase_verification_fingerprint`] once the
155    /// evidence root for the run is known.
156    ///
157    /// `None` means no artifact was observed at the start of this run — the
158    /// ordinary case for a phase being executed for the first time. It is
159    /// deliberately distinct from `Some(h)`: an artifact that EXISTS now where
160    /// the baseline recorded none was authored during this run, whereas an
161    /// artifact whose fingerprint still equals the baseline was inherited from
162    /// a previous run and its verdict must not be reused.
163    ///
164    /// **State written by a binary predating this field also deserializes to
165    /// `None`, and that is NOT the same reading** (WR-05, 35-REVIEW). This doc
166    /// comment used to claim it was. For a phase started under an older binary
167    /// and continued by this one, the previous run's committed
168    /// `{N}-VERIFICATION.md` is already on disk while the baseline reads
169    /// `None` — so the `(Some, None)` row would classify an inherited artifact
170    /// as authored-this-run and dispatch `--gaps-only` against zero matching
171    /// plans, gating unresolvably. That is verbatim the DOGFOOD-01-class stall
172    /// 999.79 exists to close, reproduced for every in-flight phase across the
173    /// upgrade.
174    ///
175    /// [`Self::verification_baseline_captured`] is the discriminator: only a
176    /// run that actually performed the observation sets it, so a `None` from an
177    /// old state file is distinguishable from a `None` that means "looked, and
178    /// there was nothing there".
179    ///
180    /// Why this exists at all: nothing deletes or dates `{N}-VERIFICATION.md`,
181    /// so a `devflow start --force` re-run checks out a branch still carrying
182    /// the previous run's committed copy. Without this baseline the first
183    /// Validate failure of that re-run reads the inherited artifact as a
184    /// verdict and dispatches a `--gaps-only` pass against zero matching plans,
185    /// which gates unresolvably — the same unattended-stall class as
186    /// DOGFOOD-01, reached from a different direction.
187    ///
188    /// **Lifetime.** Like [`Self::last_validate_failure_commit_count`], and
189    /// unlike [`Self::consecutive_failures`] and [`Self::infra_failures`], this
190    /// field is NOT touched by `transition()` — it is a run-scoped observation
191    /// rather than a counter, so it is replaced wholesale rather than
192    /// incremented and needs no `saturating_add` treatment. It is also NOT
193    /// carried across a forced restart the way
194    /// [`Self::phase_validate_failures`] is: a new run must re-observe the
195    /// artifact, because the whole point is to compare against what THIS run
196    /// started with.
197    #[serde(default)]
198    pub last_verification_fingerprint: Option<u64>,
199    /// Whether [`Self::last_verification_fingerprint`] was actually observed by
200    /// this run, as opposed to merely absent (WR-05, 35-REVIEW).
201    ///
202    /// `Option<u64>` cannot carry this on its own: `None` means both "the run
203    /// looked and found no artifact" and "this state file predates the field,
204    /// so nobody ever looked", and those two demand OPPOSITE dispatches. The
205    /// first is the ordinary first-verification case and `--gaps-only` is
206    /// right; the second may be sitting on an inherited artifact, where
207    /// `--gaps-only` matches zero plans and stalls.
208    ///
209    /// `false` is therefore the correct serde default in both directions: a
210    /// state file written before this field existed genuinely did not capture a
211    /// baseline, and the conservative reading of an artifact whose provenance
212    /// is unknown is "inherited" — a full execute is wasteful, an unresolvable
213    /// gate is not recoverable.
214    ///
215    /// Set exactly once per run, at the same site that captures the baseline,
216    /// after `state.worktree_path` holds its final value.
217    #[serde(default)]
218    pub verification_baseline_captured: bool,
219    /// The mtime of the same artifact [`Self::last_verification_fingerprint`]
220    /// hashes, in nanoseconds since the Unix epoch, as of the same observation.
221    ///
222    /// WR-06 (35-REVIEW): a content fingerprint cannot see an IDEMPOTENT
223    /// rewrite. A Validate agent that re-authors byte-identical content on a
224    /// later failing cycle produces the same hash as an artifact nobody
225    /// touched, so a hash-only rule reads its own agent's work as inherited and
226    /// dispatches a full execute — re-running every plan in the phase on every
227    /// subsequent cycle instead of the gaps-only pass Phase 33 built. That is
228    /// the "too strict" direction the freshness rule's own comment claims to
229    /// guard against and did not.
230    ///
231    /// Moves in lockstep with the fingerprint: written at the same capture
232    /// site, replaced at the same update site, and never read on its own — the
233    /// pair is the observation, and either one differing means the artifact was
234    /// written during this run.
235    ///
236    /// 35.2 D-05: mtime was considered as the provenance signal and REJECTED.
237    /// A branch checkout or worktree merge-back updates mtime exactly as a
238    /// real write does — it fails on the identical scenario
239    /// [`Self::verification_run_nonce`] exists to catch, which is why 999.89
240    /// survived 35-05's WR-06 fix. mtime is still what detects a byte-identical
241    /// rewrite INSIDE the Validate dispatch window whose bounds the nonce
242    /// establishes — provenance and freshness are different questions.
243    #[serde(default)]
244    pub last_verification_mtime_nanos: Option<u64>,
245    /// A run-owned marker stamped per Validate dispatch proving DevFlow itself
246    /// launched the agent whose output this state describes (35.2, 999.89 /
247    /// HARDEN-03, D-01).
248    ///
249    /// `None` means DevFlow never stamped a Validate dispatch for this state,
250    /// which is both the pre-35.2-state-file case and the never-dispatched
251    /// case. Both demand the conservative reading: the artifact's provenance is
252    /// unknown and `verification_authored_this_run` returns `false`.
253    ///
254    /// **Lifetime — replaced wholesale on every Validate dispatch, not
255    /// incremented across runs.** Unlike [`Self::consecutive_failures`] and
256    /// [`Self::phase_validate_failures`], this field is NOT touched by
257    /// `transition()`, and [`State::new`] resets it, so a `--force` restart
258    /// cannot inherit a previous run's stamp. The value is a monotonically
259    /// increasing counter; the predicate consults [`Option::is_some`], never
260    /// the magnitude, so saturation cannot degrade the signal.
261    ///
262    /// The write site is `launch_stage_inner` in `pipeline_launch.rs`, gated
263    /// on `Stage::Validate`, co-located with a fresh fingerprint/mtime
264    /// re-observation — the stamp and the baseline are one mechanism, and
265    /// splitting them silently restores the run-wide observation window.
266    ///
267    /// An actor who can write `.devflow/state-{N}.json` can set `stage` or
268    /// `consecutive_failures` directly; this field adds no attack surface
269    /// beyond what already exists (P-03).
270    #[serde(default)]
271    pub verification_run_nonce: Option<u64>,
272    /// When the phase started (Unix seconds).
273    pub started_at: String,
274    /// Path to the project root.
275    pub project_root: PathBuf,
276    /// Working directory for the agent when running in a git worktree.
277    ///
278    /// `None` means the agent runs in `project_root`. State and capture files
279    /// always live under the main `project_root`; only the agent's cwd changes.
280    #[serde(default)]
281    pub worktree_path: Option<PathBuf>,
282    /// PID of the detached monitor process that owns the agent for the
283    /// current stage, recorded by `launch_stage` at spawn time. `None` means
284    /// no monitor has been spawned for this state yet, OR the state was
285    /// written by a binary predating this field — in both cases the
286    /// liveness probe reports Unknown, never Stuck.
287    #[serde(default)]
288    pub monitor_pid: Option<u32>,
289    /// The Claude session id captured from the most recent captured stdout
290    /// envelope for this phase's current stage (D-04, 28-02), read via
291    /// [`crate::agent_result::session_id_from_capture`]. `None` means EITHER
292    /// "no session has been captured for this state yet" OR "the state was
293    /// written by a binary predating this field" — both cases behave
294    /// identically (no relaunch target to address). Recorded so a checkpoint
295    /// auto-decide relaunch (plan 28-03) can `--resume` the exact session
296    /// that hit the checkpoint rather than spawning a fresh one, which would
297    /// lose the original session's conversation context and permission mode.
298    #[serde(default)]
299    pub session_id: Option<String>,
300    /// How many times the current stage's agent has been relaunched via a
301    /// checkpoint auto-decide resume (D-04, 28-03). Bounds a stuck
302    /// checkpoint loop against `mode::MAX_CHECKPOINT_RESUMES` (added in plan
303    /// 28-03) the same way [`Self::infra_failures`] bounds an infra-fault
304    /// loop against `mode::MAX_INFRA_FAILURES`. Reset to 0 by every ordinary fresh stage
305    /// launch, so the ceiling bounds one stage's resume budget, not a
306    /// phase's lifetime (the same distinction `MAX_INFRA_FAILURES`' doc
307    /// comment draws for `infra_failures`). Any increment must use
308    /// `saturating_add` so a stuck loop cannot overflow `u32`. A
309    /// serde-absent value (state written by a binary predating this field)
310    /// defaults to 0.
311    #[serde(default)]
312    pub checkpoint_resumes: u32,
313    /// The stage `devflow start --until <stage>` requests as the last stage
314    /// to run before halting (20c). `None` means no stop point was
315    /// requested (the pipeline runs to Ship), OR the state was written by a
316    /// binary predating this field — both cases behave identically (no
317    /// interception in `transition()`).
318    #[serde(default)]
319    pub stop_until: Option<Stage>,
320    /// Set by `transition()` when `stop_until` names the stage just
321    /// completed — a terminal-but-not-failed halt short of Ship (20c).
322    /// `false` for a normal in-flight or completed-to-Ship phase, and for
323    /// any state written by a binary predating this field.
324    #[serde(default)]
325    pub stopped: bool,
326    /// Human-readable reason recorded alongside `stopped` (20c). `None`
327    /// when `stopped` is `false`, or when the state predates this field.
328    #[serde(default)]
329    pub stop_reason: Option<String>,
330    /// Pre-authorization for the Ship gate (D-04/D-05/D-06, 23-09),
331    /// set only from the `--yes-ship` CLI flag typed on `devflow start`.
332    ///
333    /// Persisted rather than passed through the call stack: the Ship gate
334    /// fires inside a detached monitor's `advance` process, minutes to
335    /// hours after the launching `devflow start` process has already
336    /// exited, so a CLI-scoped value would be gone by the time it matters —
337    /// only a value written to `state.json` at start time survives to be
338    /// read back by that later, separate process. `false` for any state
339    /// written by a binary predating this field.
340    #[serde(default)]
341    pub yes_ship: bool,
342    /// What this run's delivery canary established (D-13/D-15, 31-03),
343    /// recorded by the first stage launch that routes through the Claude
344    /// `stream-json` transport. `None` means EITHER "no canary has run for
345    /// this run yet" OR "the state was written by a binary predating this
346    /// field" — both cases behave identically: the canary runs.
347    ///
348    /// Persisted rather than held in memory for the same reason
349    /// [`Self::yes_ship`] is: each stage launch happens in a SEPARATE
350    /// `devflow` process (the monitor's own `advance` tail), so an
351    /// in-process flag would reset to "not yet run" at every stage
352    /// transition and re-spend a real throwaway agent invocation each time —
353    /// which is exactly the symptom 31-RESEARCH Pitfall 5 names for a canary
354    /// that landed in the per-stage `preflight` hook.
355    ///
356    /// A recorded `Absent`/`Unverified` keeps refusing on every later launch
357    /// in the run; it is not consumed by the first refusal.
358    #[serde(default)]
359    pub canary: Option<crate::canary::CanaryOutcome>,
360    /// D-11's opt-out: force the pre-31 single-document Claude launch
361    /// (positional prompt, `--output-format json`, the `sh` monitor) for this
362    /// run, off by default.
363    ///
364    /// `false` means EITHER "the operator did not ask for the legacy path" OR
365    /// "the state was written by a binary predating this field" — both cases
366    /// behave identically: the D-09/D-10 rollout decides the transport, which
367    /// is the pre-existing behaviour.
368    ///
369    /// Persisted rather than passed through the call stack for the reason
370    /// [`Self::yes_ship`] gives: each stage launch happens in a SEPARATE
371    /// `devflow` process (the detached monitor's own `advance` tail), so a
372    /// CLI-scoped value would be gone by the time the second stage launches
373    /// and the run would silently revert to the stream transport mid-flight.
374    ///
375    /// Only ever OR-ed, never cleared, once set — see
376    /// `pipeline_launch::apply_legacy_launch_opt_out`. Clearing it on a plain
377    /// `devflow resume` would be the same silent-drop class as `stop_until`'s
378    /// old unconditional clear (999.60). To turn it back off, edit
379    /// `.devflow/state-NN.json` or start a new run.
380    #[serde(default)]
381    pub legacy_claude_launch: bool,
382}
383
384/// Supported coding agents.
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(rename_all = "lowercase")]
387pub enum AgentKind {
388    /// Anthropic Claude Code CLI.
389    Claude,
390    /// OpenAI Codex CLI.
391    Codex,
392    /// OpenCode CLI.
393    OpenCode,
394    /// Pi coding-agent harness.
395    Pi,
396    /// Antigravity CLI (`agy`), stream-json transport (phase 41).
397    Antigravity,
398    /// Hermes Agent CLI (`hermes`), headless oneshot (phase 42).
399    Hermes,
400}
401
402impl fmt::Display for AgentKind {
403    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
404        let name = match self {
405            AgentKind::Claude => "claude",
406            AgentKind::Codex => "codex",
407            AgentKind::OpenCode => "opencode",
408            AgentKind::Pi => "pi",
409            AgentKind::Antigravity => "antigravity",
410            AgentKind::Hermes => "hermes",
411        };
412        f.write_str(name)
413    }
414}
415
416impl FromStr for AgentKind {
417    type Err = AgentParseError;
418
419    fn from_str(value: &str) -> Result<Self, Self::Err> {
420        match value.to_ascii_lowercase().as_str() {
421            "claude" => Ok(AgentKind::Claude),
422            "codex" => Ok(AgentKind::Codex),
423            "opencode" | "open-code" => Ok(AgentKind::OpenCode),
424            "pi" => Ok(AgentKind::Pi),
425            "antigravity" => Ok(AgentKind::Antigravity),
426            "hermes" => Ok(AgentKind::Hermes),
427            other => Err(AgentParseError(other.to_string())),
428        }
429    }
430}
431
432/// Error returned when parsing an unsupported agent name.
433#[derive(Debug, Clone, thiserror::Error)]
434#[error("unsupported agent `{0}`; expected claude, codex, opencode, pi, antigravity, or hermes")]
435pub struct AgentParseError(String);
436
437impl State {
438    /// Create a new state for starting a phase at the [`Stage::Define`] stage.
439    pub fn new(phase: PhaseId, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
440        State {
441            stage: Stage::Define,
442            phase,
443            agent,
444            mode,
445            gate_pending: false,
446            consecutive_failures: 0,
447            infra_failures: 0,
448            preflight_retries: 0,
449            last_validate_failure_commit_count: None,
450            phase_validate_failures: 0,
451            last_verification_fingerprint: None,
452            verification_baseline_captured: false,
453            last_verification_mtime_nanos: None,
454            verification_run_nonce: None,
455            started_at: timestamp_now(),
456            project_root,
457            worktree_path: None,
458            monitor_pid: None,
459            session_id: None,
460            checkpoint_resumes: 0,
461            stop_until: None,
462            stopped: false,
463            stop_reason: None,
464            yes_ship: false,
465            canary: None,
466            legacy_claude_launch: false,
467        }
468    }
469}
470
471fn timestamp_now() -> String {
472    match SystemTime::now().duration_since(UNIX_EPOCH) {
473        Ok(duration) => format!("{}", duration.as_secs()),
474        Err(_) => String::from("0"),
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use std::path::PathBuf;
482
483    #[test]
484    fn agent_name_and_display() {
485        use crate::agents::driver_for;
486        assert_eq!(driver_for(AgentKind::Claude).name(), "Claude Code");
487        assert_eq!(driver_for(AgentKind::Codex).name(), "OpenAI Codex");
488        assert_eq!(driver_for(AgentKind::OpenCode).name(), "OpenCode");
489
490        assert_eq!(AgentKind::Claude.to_string(), "claude");
491        assert_eq!(AgentKind::Codex.to_string(), "codex");
492        assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
493    }
494
495    #[test]
496    fn agent_from_str_accepts_canonical_and_aliases() {
497        assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
498        assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
499        assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
500        assert_eq!(
501            "opencode".parse::<AgentKind>().unwrap(),
502            AgentKind::OpenCode
503        );
504        assert_eq!(
505            "open-code".parse::<AgentKind>().unwrap(),
506            AgentKind::OpenCode
507        );
508    }
509
510    #[test]
511    fn agent_from_str_rejects_unknown() {
512        let err = "aider".parse::<AgentKind>().unwrap_err();
513        assert!(err.to_string().contains("aider"));
514    }
515
516    #[test]
517    fn new_state_starts_at_define() {
518        let state = State::new(
519            PhaseId::new(2),
520            AgentKind::Claude,
521            Mode::Auto,
522            PathBuf::from("/repo"),
523        );
524        assert_eq!(state.stage, Stage::Define);
525        assert_eq!(state.phase, PhaseId::new(2));
526        assert_eq!(state.agent, AgentKind::Claude);
527        assert_eq!(state.mode, Mode::Auto);
528        assert!(!state.gate_pending);
529        assert_eq!(state.consecutive_failures, 0);
530        assert_eq!(state.infra_failures, 0);
531        assert_eq!(state.preflight_retries, 0);
532        assert_eq!(state.phase_validate_failures, 0);
533        assert!(!state.started_at.is_empty());
534        assert_eq!(state.monitor_pid, None);
535        assert_eq!(state.stop_until, None);
536        assert!(!state.stopped);
537        assert_eq!(state.stop_reason, None);
538        assert!(!state.yes_ship);
539    }
540
541    #[test]
542    fn state_serde_round_trips() {
543        let state = State::new(
544            PhaseId::new(9),
545            AgentKind::Codex,
546            Mode::Supervise,
547            PathBuf::from("/repo"),
548        );
549        let json = serde_json::to_string(&state).unwrap();
550        let back: State = serde_json::from_str(&json).unwrap();
551        assert_eq!(back.phase, PhaseId::new(9));
552        assert_eq!(back.agent, AgentKind::Codex);
553        assert_eq!(back.stage, Stage::Define);
554        assert_eq!(back.mode, Mode::Supervise);
555    }
556
557    #[test]
558    fn consecutive_failures_persists_across_advance_calls() {
559        let mut state = State::new(
560            PhaseId::new(1),
561            AgentKind::Claude,
562            Mode::Auto,
563            PathBuf::from("/repo"),
564        );
565        state.consecutive_failures = 3;
566        let json = serde_json::to_string(&state).unwrap();
567        assert!(
568            json.contains("consecutive_failures"),
569            "consecutive_failures must appear in persisted JSON"
570        );
571        let loaded: State = serde_json::from_str(&json).unwrap();
572        assert_eq!(
573            loaded.consecutive_failures, 3,
574            "consecutive_failures must round-trip through serde"
575        );
576    }
577
578    /// D-08 (17-01): a distinct infra-failure counter round-trips through
579    /// serde and its own key appears in the persisted JSON.
580    #[test]
581    fn infra_failures_round_trips_through_serde() {
582        let mut state = State::new(
583            PhaseId::new(1),
584            AgentKind::Claude,
585            Mode::Auto,
586            PathBuf::from("/repo"),
587        );
588        state.infra_failures = 4;
589        let json = serde_json::to_string(&state).unwrap();
590        assert!(
591            json.contains("infra_failures"),
592            "infra_failures must appear in persisted JSON"
593        );
594        let loaded: State = serde_json::from_str(&json).unwrap();
595        assert_eq!(
596            loaded.infra_failures, 4,
597            "infra_failures must round-trip through serde"
598        );
599    }
600
601    /// A serde-absent `infra_failures` (older persisted state.json without
602    /// the field) must default to 0, not fail to deserialize.
603    #[test]
604    fn infra_failures_absent_from_json_defaults_to_zero() {
605        let json = r#"{
606            "stage": "code",
607            "phase": 1,
608            "agent": "claude",
609            "mode": "auto",
610            "started_at": "0",
611            "project_root": "/repo"
612        }"#;
613        let loaded: State = serde_json::from_str(json).unwrap();
614        assert_eq!(loaded.infra_failures, 0);
615    }
616
617    /// `last_validate_failure_commit_count` round-trips through serde as an
618    /// exact `Option<u32>` (999.66, D-03) — its own key appears in the
619    /// persisted JSON before the value round-trip is asserted, so a field
620    /// accidentally attributed `skip_serializing_if` (which would still pass
621    /// a naive in-memory round-trip while never persisting anything) is
622    /// caught.
623    #[test]
624    fn last_validate_failure_commit_count_round_trips_through_serde() {
625        let mut state = State::new(
626            PhaseId::new(1),
627            AgentKind::Claude,
628            Mode::Auto,
629            PathBuf::from("/repo"),
630        );
631        state.last_validate_failure_commit_count = Some(3);
632        let json = serde_json::to_string(&state).unwrap();
633        assert!(
634            json.contains("last_validate_failure_commit_count"),
635            "last_validate_failure_commit_count must appear in persisted JSON"
636        );
637        let loaded: State = serde_json::from_str(&json).unwrap();
638        assert_eq!(
639            loaded.last_validate_failure_commit_count,
640            Some(3),
641            "last_validate_failure_commit_count must round-trip through serde"
642        );
643    }
644
645    /// A serde-absent `last_validate_failure_commit_count` (state written by
646    /// a binary predating this field) must deserialize to `None` — the
647    /// "no prior failure recorded" meaning — not to `Some(0)`, which would
648    /// misrepresent a never-observed baseline as an observed zero.
649    #[test]
650    fn last_validate_failure_commit_count_absent_from_json_defaults_to_none() {
651        let json = r#"{
652            "stage": "code",
653            "phase": 1,
654            "agent": "claude",
655            "mode": "auto",
656            "started_at": "0",
657            "project_root": "/repo"
658        }"#;
659        let loaded: State = serde_json::from_str(json).unwrap();
660        assert_eq!(loaded.last_validate_failure_commit_count, None);
661    }
662
663    /// 999.78/D-07: `phase_validate_failures` round-trips through serde. The
664    /// key-presence assertion comes BEFORE the value round-trip deliberately —
665    /// a field that never actually persists still passes a naive in-memory
666    /// round trip, and a bound that lives only in memory does not bound a
667    /// phase whose whole failure mode spans separate `devflow` processes.
668    #[test]
669    fn phase_validate_failures_round_trips_through_serde() {
670        let mut state = State::new(
671            PhaseId::new(1),
672            AgentKind::Claude,
673            Mode::Auto,
674            PathBuf::from("/repo"),
675        );
676        state.phase_validate_failures = 7;
677        let json = serde_json::to_string(&state).unwrap();
678        assert!(
679            json.contains("phase_validate_failures"),
680            "phase_validate_failures must appear in persisted JSON"
681        );
682        let loaded: State = serde_json::from_str(&json).unwrap();
683        assert_eq!(
684            loaded.phase_validate_failures, 7,
685            "phase_validate_failures must round-trip through serde"
686        );
687    }
688
689    /// A serde-absent `phase_validate_failures` (state written by a binary
690    /// predating this field) deserializes to 0 — "no failures recorded for
691    /// this phase" — rather than failing the load outright, which would make
692    /// an upgrade mid-phase unrecoverable.
693    #[test]
694    fn phase_validate_failures_absent_from_json_defaults_to_zero() {
695        let json = r#"{
696            "stage": "code",
697            "phase": 1,
698            "agent": "claude",
699            "mode": "auto",
700            "started_at": "0",
701            "project_root": "/repo"
702        }"#;
703        let loaded: State = serde_json::from_str(json).unwrap();
704        assert_eq!(loaded.phase_validate_failures, 0);
705    }
706
707    /// 999.79 (35-05): `last_verification_fingerprint` round-trips through
708    /// serde. The key-presence assertion comes BEFORE the value round-trip for
709    /// the same reason the two fields above give — this baseline is written by
710    /// `devflow start` and compared by a later `devflow advance`, which is a
711    /// different process, so a field that never reaches disk would leave every
712    /// comparison reading `None` and defeat the whole rule.
713    #[test]
714    fn last_verification_fingerprint_round_trips_through_serde() {
715        let mut state = State::new(
716            PhaseId::new(1),
717            AgentKind::Claude,
718            Mode::Auto,
719            PathBuf::from("/repo"),
720        );
721        state.last_verification_fingerprint = Some(0x0123_4567_89ab_cdef);
722        let json = serde_json::to_string(&state).unwrap();
723        assert!(
724            json.contains("last_verification_fingerprint"),
725            "last_verification_fingerprint must appear in persisted JSON"
726        );
727        let loaded: State = serde_json::from_str(&json).unwrap();
728        assert_eq!(
729            loaded.last_verification_fingerprint,
730            Some(0x0123_4567_89ab_cdef),
731            "last_verification_fingerprint must round-trip through serde"
732        );
733    }
734
735    /// A serde-absent `last_verification_fingerprint` (state written by a
736    /// binary predating this field) deserializes to `None` — "no artifact was
737    /// observed at the start of this run" — rather than failing the load, which
738    /// would make an upgrade mid-phase unrecoverable.
739    #[test]
740    fn last_verification_fingerprint_absent_from_json_defaults_to_none() {
741        let json = r#"{
742            "stage": "code",
743            "phase": 1,
744            "agent": "claude",
745            "mode": "auto",
746            "started_at": "0",
747            "project_root": "/repo"
748        }"#;
749        let loaded: State = serde_json::from_str(json).unwrap();
750        assert_eq!(loaded.last_verification_fingerprint, None);
751        // WR-05 (35-REVIEW): the SAME absent JSON must also report that nobody
752        // captured a baseline. `None` alone cannot carry that — it means both
753        // "looked, found nothing" and "never looked" — and the two demand
754        // opposite dispatches downstream.
755        assert!(
756            !loaded.verification_baseline_captured,
757            "state predating the baseline field never captured one, and must not claim to"
758        );
759    }
760
761    /// The other half of the pair above: a state file written by THIS binary
762    /// carries the flag, so the two cases really are distinguishable after a
763    /// round trip. Without this, `verification_baseline_captured` could be
764    /// hardcoded `false` and the absent-JSON assertion above would still pass.
765    #[test]
766    fn verification_baseline_captured_round_trips_through_serde() {
767        let mut state = State::new(
768            PhaseId::new(1),
769            AgentKind::Claude,
770            Mode::Auto,
771            PathBuf::from("/repo"),
772        );
773        state.verification_baseline_captured = true;
774        let json = serde_json::to_string(&state).unwrap();
775        assert!(
776            json.contains("verification_baseline_captured"),
777            "verification_baseline_captured must appear in persisted JSON"
778        );
779        let loaded: State = serde_json::from_str(&json).unwrap();
780        assert!(
781            loaded.verification_baseline_captured,
782            "a captured baseline must survive the save/load the real pipeline performs"
783        );
784    }
785
786    /// 35.2 D-01: verification_run_nonce must survive the save/load cycle
787    /// `handle_validate_outcome` → `select_loop_back_fix` performs.
788    #[test]
789    fn verification_run_nonce_round_trips_through_serde() {
790        let mut state = State::new(
791            PhaseId::new(1),
792            AgentKind::Claude,
793            Mode::Auto,
794            PathBuf::from("/repo"),
795        );
796        state.verification_run_nonce = Some(42);
797        let json = serde_json::to_string(&state).unwrap();
798        assert!(
799            json.contains("verification_run_nonce"),
800            "verification_run_nonce must appear in persisted JSON"
801        );
802        let loaded: State = serde_json::from_str(&json).unwrap();
803        assert_eq!(
804            loaded.verification_run_nonce,
805            Some(42),
806            "verification_run_nonce must round-trip through serde"
807        );
808    }
809
810    /// 35.2 D-01: a serde-absent verification_run_nonce (state written by
811    /// a pre-35.2 binary) deserializes to None — the conservative direction.
812    #[test]
813    fn verification_run_nonce_absent_from_json_defaults_to_none() {
814        let json = r#"{
815            "stage": "code",
816            "phase": 1,
817            "agent": "claude",
818            "mode": "auto",
819            "started_at": "0",
820            "project_root": "/repo"
821        }"#;
822        let loaded: State = serde_json::from_str(json).unwrap();
823        assert_eq!(
824            loaded.verification_run_nonce, None,
825            "pre-35.2 state must default to None — the conservative provenance reading"
826        );
827    }
828
829    /// D-18f: `preflight_retries` round-trips through serde (its own key
830    /// appears in the persisted JSON) — the wedge this counter bounds spans
831    /// separate `devflow` invocations, so it must survive a save/load
832    /// cycle, not just live in memory — and a serde-absent value (state
833    /// written by a pre-18f binary) deserializes to 0, not a hard error.
834    #[test]
835    fn preflight_retries_round_trips_through_serde() {
836        let mut state = State::new(
837            PhaseId::new(1),
838            AgentKind::Claude,
839            Mode::Auto,
840            PathBuf::from("/repo"),
841        );
842        state.preflight_retries = 2;
843        let json = serde_json::to_string(&state).unwrap();
844        assert!(
845            json.contains("preflight_retries"),
846            "preflight_retries must appear in persisted JSON"
847        );
848        let loaded: State = serde_json::from_str(&json).unwrap();
849        assert_eq!(
850            loaded.preflight_retries, 2,
851            "preflight_retries must round-trip through serde"
852        );
853
854        let absent_json = r#"{
855            "stage": "code",
856            "phase": 1,
857            "agent": "claude",
858            "mode": "auto",
859            "started_at": "0",
860            "project_root": "/repo"
861        }"#;
862        let loaded_absent: State = serde_json::from_str(absent_json).unwrap();
863        assert_eq!(loaded_absent.preflight_retries, 0);
864    }
865
866    /// `monitor_pid` round-trips through serde as an exact `u32` (18b).
867    #[test]
868    fn monitor_pid_round_trips_through_serde() {
869        let mut state = State::new(
870            PhaseId::new(1),
871            AgentKind::Claude,
872            Mode::Auto,
873            PathBuf::from("/repo"),
874        );
875        state.monitor_pid = Some(4242);
876        let json = serde_json::to_string(&state).unwrap();
877        assert!(
878            json.contains("monitor_pid"),
879            "monitor_pid must appear in persisted JSON"
880        );
881        let loaded: State = serde_json::from_str(&json).unwrap();
882        assert_eq!(
883            loaded.monitor_pid,
884            Some(4242),
885            "monitor_pid must round-trip through serde"
886        );
887    }
888
889    /// A serde-absent `monitor_pid` (state written by a pre-18b binary) must
890    /// deserialize to `None`, not `Some(0)` — a `Some(0)` default would let a
891    /// pre-18b state file render as a monitor at pid 0.
892    #[test]
893    fn monitor_pid_absent_from_json_defaults_to_none() {
894        let json = r#"{
895            "stage": "code",
896            "phase": 1,
897            "agent": "claude",
898            "mode": "auto",
899            "started_at": "0",
900            "project_root": "/repo"
901        }"#;
902        let loaded: State = serde_json::from_str(json).unwrap();
903        assert_eq!(loaded.monitor_pid, None);
904    }
905
906    /// `session_id` round-trips through serde as an exact `Option<String>`
907    /// (D-04, 28-02) — mirrors the `monitor_pid` pair above.
908    #[test]
909    fn session_id_round_trips_through_serde() {
910        let mut state = State::new(
911            PhaseId::new(1),
912            AgentKind::Claude,
913            Mode::Auto,
914            PathBuf::from("/repo"),
915        );
916        state.session_id = Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e".to_string());
917        let json = serde_json::to_string(&state).unwrap();
918        assert!(
919            json.contains("session_id"),
920            "session_id must appear in persisted JSON"
921        );
922        let loaded: State = serde_json::from_str(&json).unwrap();
923        assert_eq!(
924            loaded.session_id.as_deref(),
925            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e"),
926            "session_id must round-trip through serde"
927        );
928    }
929
930    /// A serde-absent `session_id` (state written by a pre-28-02 binary) must
931    /// deserialize to `None`, not fail to deserialize.
932    #[test]
933    fn session_id_absent_from_json_defaults_to_none() {
934        let json = r#"{
935            "stage": "code",
936            "phase": 1,
937            "agent": "claude",
938            "mode": "auto",
939            "started_at": "0",
940            "project_root": "/repo"
941        }"#;
942        let loaded: State = serde_json::from_str(json).unwrap();
943        assert_eq!(loaded.session_id, None);
944    }
945
946    /// `checkpoint_resumes` round-trips through serde as an exact `u32`
947    /// (D-04, 28-02).
948    #[test]
949    fn checkpoint_resumes_round_trips_through_serde() {
950        let mut state = State::new(
951            PhaseId::new(1),
952            AgentKind::Claude,
953            Mode::Auto,
954            PathBuf::from("/repo"),
955        );
956        state.checkpoint_resumes = 2;
957        let json = serde_json::to_string(&state).unwrap();
958        assert!(
959            json.contains("checkpoint_resumes"),
960            "checkpoint_resumes must appear in persisted JSON"
961        );
962        let loaded: State = serde_json::from_str(&json).unwrap();
963        assert_eq!(
964            loaded.checkpoint_resumes, 2,
965            "checkpoint_resumes must round-trip through serde"
966        );
967    }
968
969    /// A serde-absent `checkpoint_resumes` (state written by a pre-28-02
970    /// binary) must deserialize to `0`, not fail to deserialize.
971    #[test]
972    fn checkpoint_resumes_absent_from_json_defaults_to_zero() {
973        let json = r#"{
974            "stage": "code",
975            "phase": 1,
976            "agent": "claude",
977            "mode": "auto",
978            "started_at": "0",
979            "project_root": "/repo"
980        }"#;
981        let loaded: State = serde_json::from_str(json).unwrap();
982        assert_eq!(loaded.checkpoint_resumes, 0);
983    }
984
985    /// 23-09 Task 1: `yes_ship` round-trips through serde as an exact `bool`
986    /// — its own key appears in the persisted JSON, and a fresh deserialize
987    /// recovers the value set, mirroring the `monitor_pid` pair above.
988    #[test]
989    fn yes_ship_round_trips_through_serde() {
990        let mut state = State::new(
991            PhaseId::new(1),
992            AgentKind::Claude,
993            Mode::Auto,
994            PathBuf::from("/repo"),
995        );
996        state.yes_ship = true;
997        let json = serde_json::to_string(&state).unwrap();
998        assert!(
999            json.contains("yes_ship"),
1000            "yes_ship must appear in persisted JSON"
1001        );
1002        let loaded: State = serde_json::from_str(&json).unwrap();
1003        assert!(loaded.yes_ship, "yes_ship must round-trip through serde");
1004    }
1005
1006    /// A serde-absent `yes_ship` (state written by a pre-23-09 binary) must
1007    /// deserialize to `false`, not fail to deserialize — the same
1008    /// backward-compat pattern as every other `#[serde(default)]` field
1009    /// added since 17-01.
1010    #[test]
1011    fn yes_ship_absent_from_json_defaults_to_false() {
1012        let json = r#"{
1013            "stage": "code",
1014            "phase": 1,
1015            "agent": "claude",
1016            "mode": "auto",
1017            "started_at": "0",
1018            "project_root": "/repo"
1019        }"#;
1020        let loaded: State = serde_json::from_str(json).unwrap();
1021        assert!(!loaded.yes_ship);
1022    }
1023
1024    /// 20c: `stop_until`/`stopped`/`stop_reason` all round-trip through
1025    /// serde — each field's own key appears in the persisted JSON, and a
1026    /// fresh deserialize recovers the exact values set.
1027    #[test]
1028    fn stop_fields_round_trip_through_serde() {
1029        let mut state = State::new(
1030            PhaseId::new(1),
1031            AgentKind::Claude,
1032            Mode::Auto,
1033            PathBuf::from("/repo"),
1034        );
1035        state.stop_until = Some(Stage::Plan);
1036        state.stopped = true;
1037        state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
1038        let json = serde_json::to_string(&state).unwrap();
1039        assert!(
1040            json.contains("stop_until") && json.contains("stopped") && json.contains("stop_reason"),
1041            "all three stop fields must appear in persisted JSON: {json}"
1042        );
1043        let loaded: State = serde_json::from_str(&json).unwrap();
1044        assert_eq!(
1045            loaded.stop_until,
1046            Some(Stage::Plan),
1047            "stop_until must round-trip through serde"
1048        );
1049        assert!(loaded.stopped, "stopped must round-trip through serde");
1050        assert_eq!(
1051            loaded.stop_reason.as_deref(),
1052            Some("stopped after plan completed (--until plan)"),
1053            "stop_reason must round-trip through serde"
1054        );
1055    }
1056
1057    /// A serde-absent `stop_until`/`stopped`/`stop_reason` (state written by
1058    /// a pre-20c binary) must default to `None`/`false`/`None`, not fail to
1059    /// deserialize — the same backward-compat pattern as every other
1060    /// `#[serde(default)]` field added since 17-01.
1061    #[test]
1062    fn stop_fields_absent_from_json_default() {
1063        let json = r#"{
1064            "stage": "code",
1065            "phase": 1,
1066            "agent": "claude",
1067            "mode": "auto",
1068            "started_at": "0",
1069            "project_root": "/repo"
1070        }"#;
1071        let loaded: State = serde_json::from_str(json).unwrap();
1072        assert_eq!(loaded.stop_until, None);
1073        assert!(!loaded.stopped);
1074        assert_eq!(loaded.stop_reason, None);
1075    }
1076
1077    // ------------------------------------------------------------------
1078    // Phase 41 Task 6: AgentKind::Antigravity (unique test prefix so the
1079    // verify filter matches ONLY the new work — F6).
1080    // ------------------------------------------------------------------
1081
1082    #[test]
1083    fn agent_kind_antigravity_from_str_is_case_insensitive() {
1084        assert_eq!(
1085            "antigravity".parse::<AgentKind>().unwrap(),
1086            AgentKind::Antigravity
1087        );
1088        assert_eq!(
1089            "ANTIGRAVITY".parse::<AgentKind>().unwrap(),
1090            AgentKind::Antigravity
1091        );
1092        assert_eq!(
1093            "Antigravity".parse::<AgentKind>().unwrap(),
1094            AgentKind::Antigravity
1095        );
1096    }
1097
1098    #[test]
1099    fn agent_kind_antigravity_display_is_lowercase() {
1100        assert_eq!(AgentKind::Antigravity.to_string(), "antigravity");
1101    }
1102
1103    #[test]
1104    fn agent_kind_antigravity_serde_round_trips_lowercase() {
1105        let wire = serde_json::to_string(&AgentKind::Antigravity).unwrap();
1106        assert_eq!(wire, "\"antigravity\"");
1107        let back: AgentKind = serde_json::from_str(&wire).unwrap();
1108        assert_eq!(back, AgentKind::Antigravity);
1109    }
1110
1111    #[test]
1112    fn agent_kind_antigravity_error_message_lists_it() {
1113        let err = "aider".parse::<AgentKind>().unwrap_err();
1114        assert!(
1115            err.to_string().contains("antigravity"),
1116            "the parse error must advertise the new agent: {err}"
1117        );
1118    }
1119
1120    #[test]
1121    fn agent_kind_antigravity_driver_for_resolves() {
1122        assert_eq!(
1123            crate::agents::driver_for(AgentKind::Antigravity).name(),
1124            "Antigravity"
1125        );
1126    }
1127
1128    // ------------------------------------------------------------------
1129    // Phase 42 Task 2: AgentKind::Hermes (HRMS-01, D-05, D-06).
1130    // ------------------------------------------------------------------
1131
1132    #[test]
1133    fn agent_kind_hermes_from_str_is_case_insensitive() {
1134        assert_eq!("hermes".parse::<AgentKind>().unwrap(), AgentKind::Hermes);
1135        assert_eq!("HERMES".parse::<AgentKind>().unwrap(), AgentKind::Hermes);
1136        assert_eq!("Hermes".parse::<AgentKind>().unwrap(), AgentKind::Hermes);
1137    }
1138
1139    #[test]
1140    fn agent_kind_hermes_display_is_lowercase() {
1141        assert_eq!(AgentKind::Hermes.to_string(), "hermes");
1142    }
1143
1144    #[test]
1145    fn agent_kind_hermes_serde_round_trips_lowercase() {
1146        let wire = serde_json::to_string(&AgentKind::Hermes).unwrap();
1147        assert_eq!(wire, "\"hermes\"");
1148        let back: AgentKind = serde_json::from_str(&wire).unwrap();
1149        assert_eq!(back, AgentKind::Hermes);
1150    }
1151
1152    #[test]
1153    fn agent_kind_hermes_error_message_lists_it() {
1154        let err = "aider".parse::<AgentKind>().unwrap_err();
1155        assert!(
1156            err.to_string().contains("hermes"),
1157            "the parse error must advertise the new agent: {err}"
1158        );
1159    }
1160
1161    #[test]
1162    fn agent_kind_hermes_driver_for_resolves() {
1163        assert_eq!(
1164            crate::agents::driver_for(AgentKind::Hermes).name(),
1165            "Hermes"
1166        );
1167    }
1168}