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
- 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.
- 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.
- 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.
- 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.
- 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
- Pair every skip_serializing_if with #[serde(default)] so anything omitted from the wire can still be read back.
- Never remove #[serde(default)] from parent/budget/terminal/refs — minimal payloads legitimately omit them.
- Keep #[serde(rename_all = "snake_case")] on RunSource/RunState/TerminalOutcome/ReceiptKind; the stable-wire-names test pins this.
- When adding fields to a persisted schema, default them so older stored JSON keeps deserializing (see the legacy-receipt pattern in fleet.rs).
- Read the serde error message — it names the exact field and JSON path — instead of collapsing it with expect().
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
- roundtrip
- continue_goal with a wire-supplied schedule id still parses
- Fleet task has an invalid durable member snapshot
- <serde_json deserialization error>
- (serde_json deserialization error wrapped as…
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)