Hmbown/CodeWhale · error

deserialize

Error message

deserialize

What it means

This is the panic from `serde_json::from_str::<AgentRunSnapshot>(&json).expect("deserialize")` in `full_snapshot_round_trips` at crates/protocol/src/agent_run.rs:255. The test first serializes a fully-populated snapshot and then requires the JSON to deserialize back into an identical `AgentRunSnapshot`. It panics when the produced JSON cannot be parsed into the type — missing required (non-`#[serde(default)]`) fields, enum strings that do not match any snake_case variant, or type mismatches (e.g. a number where a string is expected). Because the payload here is produced by the same type's serializer, a panic usually means required fields are absent (some field gained `skip_serializing_if` without `default`) or an enum's `rename_all = "snake_case"` contract was broken so the written variant name no longer parses.

Solutions

  1. Read the serde_json error detail: replace `.expect("deserialize")` temporarily with a closure that prints the error (serde errors name the exact missing field or wrong type at a path) — this pinpoints the offending field immediately.
  2. For every field missing from the payload, add `#[serde(default)]` (or `#[serde(default, skip_serializing_if = "Option::is_none")]` for optional ones) so omission on the wire is tolerated, matching how `budget`, `parent`, `terminal`, and `refs` are already annotated.
  3. If the failure is an unknown enum string, confirm `#[serde(rename_all = "snake_case")]` is intact on all four enums and that the JSON uses the lowercase wire names the `enum_wire_names_are_snake_case_and_stable` test pins.
  4. If deserializing hand-written or legacy JSON rather than freshly serialized output, add `#[serde(default)]` on newly required fields or a `#[serde(alias = "old_name")]` for renames so old payloads still parse.
  5. Re-run: `cargo test -p codewhale-protocol full_snapshot_round_trips`.

Example fix

// before
pub struct AgentRunSnapshot {
    #[serde(skip_serializing_if = "Option::is_none")] // omitting parent breaks deserialize
    pub parent: Option<String>,
    pub state: RunState, // missing default: legacy JSON without `state` fails
}
// after
pub struct AgentRunSnapshot {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent: Option<String>,
    #[serde(default)]
    pub state: RunState,
}
Defensive patterns

Strategy: validation

Validate before calling

// Validate a payload before deserializing into AgentRunSnapshot
fn can_deserialize_snapshot(json: &str) -> bool {
    serde_json::from_str::<crate::agent_run::AgentRunSnapshot>(json).is_ok()
}
if !can_deserialize_snapshot(&json) { /* handle legacy/invalid payload */ }

Type guard

fn parse_snapshot(json: &str) -> Option<crate::agent_run::AgentRunSnapshot> {
    serde_json::from_str(json).ok()
}

Try / catch

match serde_json::from_str::<AgentRunSnapshot>(&json) {
    Ok(snap) => use(snap),
    Err(e) => log::warn!("snapshot deserialize failed: {e}"), // error names the missing field / bad enum
}

Prevention

When it happens

Trigger: Calling `serde_json::from_str::<AgentRunSnapshot>` on JSON where: (1) a field without `#[serde(default)]` is missing (typical cause: a required field gained `skip_serializing_if = ...` without `default`, so serialization omits it and deserialization then rejects the missing key); (2) an enum field contains a string not matching a variant — e.g. "Running" instead of "running" after dropping `#[serde(rename_all = "snake_case")]` from `RunState`/`RunSource`/`TerminalOutcome`/`ReceiptKind`; (3) a field's JSON type changed (string vs number, object vs array); (4) malformed/hand-written JSON is fed directly, e.g. replaying an old ledger payload missing a required key.

Common situations: A contributor adds a non-optional field to `AgentRunSnapshot` (or moves `default`/`skip_serializing_if` around) and the round-trip test panics with "deserialize" at line 255. Also common when wire-format migrations change enum casing or rename fields: stored JSON from an older version no longer deserializes (the sibling receipt test in fleet.rs shows the intended pattern — `#[serde(default)]` keeps legacy payloads readable).

Understand the failure class

Background: "failed to unmarshal" / json.Unmarshal errors: why parsing a response into a Go struct fails and how to fix it — this error's family across 23 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15). Data as JSON: /api/errors/bb1fee759f53bf27. Report an issue: GitHub.

Appendix: source

Thrown at crates/protocol/src/agent_run.rs:255

    }

    fn minimal_snapshot() -> AgentRunSnapshot {
        AgentRunSnapshot {
            run_id: "job-42".to_string(),
            parent: None,
            source: RunSource::CoreJob,
            state: RunState::Queued,
            budget: BudgetSummary::default(),
            terminal: None,
            refs: Vec::new(),
        }
    }

    #[test]
    fn full_snapshot_round_trips() {
        let snapshot = full_snapshot();
        let json = serde_json::to_string(&snapshot).expect("serialize");
        let back: AgentRunSnapshot = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back, snapshot);
        assert!(back.is_coherent());
    }

    #[test]
    fn minimal_snapshot_round_trips_and_skips_empty_fields() {
        let snapshot = minimal_snapshot();
        let json = serde_json::to_string(&snapshot).expect("serialize");
        // Optional/empty fields stay off the wire entirely.
        assert!(!json.contains("parent"));
        assert!(!json.contains("terminal"));
        assert!(!json.contains("refs"));
        assert!(!json.contains("token_budget"));
        let back: AgentRunSnapshot = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back, snapshot);
        assert!(back.is_coherent());
    }

View on GitHub (pinned to 433685b202)