Add explicit AvatarState serde round-trip and creation-invariant tests

Adds focused tests proving the AvatarState back-compat guarantee directly,
the load-bearing safety net flagged in review:

- legacy bare string -> Set(url)
- JSON null -> Unmigrated
- absent field (via #[serde(default)]) -> Unmigrated
- Cleared -> serialize to {"cleared":true} -> deserialize -> Cleared
  (forward-only sentinel round-trip; the case guarding a future refactor
  from silently breaking the cleared-avatar marker)
- Set/Unmigrated round-trip

Also adds the creation invariant: resolve_created_avatar_url with no input,
no persona, and an unresolvable command yields Unmigrated, never Cleared --
Cleared must be reachable only via an explicit user clear through the update
path. Bumps the types.rs file-size override 1100 -> 1180 for the added tests.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-23 18:41:07 -07:00
co-authored by Taylor Ho
parent a95c13a01a
commit 622c826734
3 changed files with 93 additions and 1 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ const overrides = new Map([
// Option<String> avatar field — a load-bearing state-model fix that closes
// the clear-vs-backfill ambiguity, not generic debt growth. Approved
// override; still queued to split with the rest of this list.
["src-tauri/src/managed_agents/types.rs", 1100],
["src-tauri/src/managed_agents/types.rs", 1180],
["src-tauri/src/managed_agents/personas.rs", 1080],
["src-tauri/src/managed_agents/persona_card.rs", 1050],
// applyWorkspace reposDir parameter threaded through the Tauri invoke for
@@ -170,3 +170,17 @@ fn legacy_avatar_empty_when_nothing_resolves() {
assert!(resolved.is_empty());
}
/// Invariant: no creation/constructor path can ever produce `AvatarState::Cleared`.
/// `Cleared` must be reachable ONLY through an explicit user clear via the update
/// path — if creation could mint it, the mutual-exclusivity guarantee leaks and a
/// brand-new avatarless agent would be wrongly excluded from relay/persona backfill.
/// With no input, no persona, and an unknown command (so the command fallback
/// yields nothing), the result must be `Unmigrated`, never `Cleared`.
#[test]
fn created_avatar_never_cleared_without_explicit_clear() {
let resolved = resolve_created_avatar_url(None, None, "totally-unknown-command");
assert_eq!(resolved, AvatarState::Unmigrated);
assert_ne!(resolved, AvatarState::Cleared);
}
@@ -834,6 +834,84 @@ mod tests {
assert_eq!(record.auth_tag, record2.auth_tag);
}
// ── AvatarState serde round-trip tests ───────────────────────────────
// These exercise all four on-disk cases directly on `AvatarState`, which
// is the load-bearing back-compat guarantee: every legacy record must land
// on a sane state, and the `Cleared` sentinel must round-trip forever.
/// A legacy bare-string avatar deserializes as an explicit `Set(url)`.
#[test]
fn avatar_state_legacy_string_deserializes_as_set() {
let state: AvatarState =
serde_json::from_str(r#""https://x/avatar.png""#).expect("string should deserialize");
assert_eq!(state, AvatarState::Set("https://x/avatar.png".to_string()));
}
/// JSON `null` maps to `Unmigrated`, preserving the legacy backfill path.
#[test]
fn avatar_state_null_deserializes_as_unmigrated() {
let state: AvatarState = serde_json::from_str("null").expect("null should deserialize");
assert_eq!(state, AvatarState::Unmigrated);
}
/// An absent field (via `#[serde(default)]` on the struct) yields
/// `Unmigrated` — same as `null`. We assert this through a record whose
/// `avatar_url` key is omitted entirely.
#[test]
fn avatar_state_absent_field_defaults_to_unmigrated() {
let record: ManagedAgentRecord = serde_json::from_str(
r#"{
"pubkey": "abcd1234",
"name": "test-agent",
"private_key_nsec": "nsec1fake",
"relay_url": "wss://localhost:3000",
"acp_command": "buzz-acp",
"agent_command": "goose",
"agent_args": [],
"mcp_command": "",
"turn_timeout_seconds": 320,
"system_prompt": null,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"last_started_at": null,
"last_stopped_at": null,
"last_exit_code": null,
"last_error": null
}"#,
)
.expect("record with absent avatar_url should deserialize");
assert_eq!(record.avatar_url, AvatarState::Unmigrated);
}
/// The forward-only case that matters most: `Cleared` serializes to the
/// self-describing `{"cleared":true}` sentinel and deserializes back to
/// `Cleared`. This is the only thing standing between a future refactor and
/// silently breaking the cleared-avatar sentinel.
#[test]
fn avatar_state_cleared_round_trips_via_sentinel() {
let serialized =
serde_json::to_string(&AvatarState::Cleared).expect("Cleared should serialize");
assert_eq!(serialized, r#"{"cleared":true}"#);
let back: AvatarState =
serde_json::from_str(&serialized).expect("sentinel should deserialize");
assert_eq!(back, AvatarState::Cleared);
}
/// `Set` and `Unmigrated` also round-trip cleanly through serialize +
/// deserialize, closing the loop on every variant.
#[test]
fn avatar_state_set_and_unmigrated_round_trip() {
for state in [
AvatarState::Set("https://x/a.png".to_string()),
AvatarState::Unmigrated,
] {
let serialized = serde_json::to_string(&state).expect("should serialize");
let back: AvatarState = serde_json::from_str(&serialized).expect("should round-trip");
assert_eq!(back, state);
}
}
// ── Inbound author gate tests ────────────────────────────────────────
use super::{validate_respond_to_allowlist, RespondTo};