From 99936c80695e54f97e649cec3730820764d937cb Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:17:23 -0500 Subject: [PATCH] test(desktop): pin the RelayAgentInfo Tauri payload to snake_case RelayAgentInfo is the payload of list_relay_agents and revalidate_relay_agents, and the frontend maps it by hand: fromRawRelayAgent in desktop/src/shared/api/tauri.ts reads owner_pubkey, agent_type, channel_ids, respond_to and respond_to_allowlist and rewrites them to camelCase. That contract rests entirely on this struct not carrying #[serde(rename_all = "camelCase")]. Adding it is the reflex when a Rust struct feeds a TypeScript client, and here it fails silently: RawRelayAgent marks all five fields optional, so the mapper does not throw -- it yields null / [] / null -- and nothing in the repo asserts the wire shape, so no test goes red. Every relay agent would arrive owner-less, typeless, channel-less and with respondTo null, which relayAgentIsSharedWithUser treats as deny. The directory would stop being mentionable on a green build. The blast radius grew recently: owner_pubkey and channel_ids joined this struct after the mapper was written, so it is five hand-mapped fields now. The test asserts both halves -- the snake_case keys are present with the expected values, and the camelCase spellings are absent -- because only the second half catches an added rename_all. Checked by breaking it, not by watching it pass: with rename_all = "camelCase" the test fails on the owner_pubkey assertion, and passes again once removed. It sits with the existing serde-contract tests in the same file (respond_to_serde_is_kebab_case, and the needs_restart / restart_diff snake_case assertion), and runs in CI through just desktop-tauri-test. Split out of #5483, closed as superseded by #6086, #6182 and #6224 -- those implemented the directory work it proposed, but nothing carried this contract across. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- .../src/managed_agents/types/tests.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 0ae584e4acd..7e06c6c0d4f 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -799,3 +799,62 @@ fn summary_with_drift_serializes_restart_diff_entries() { }])) ); } + +use super::RelayAgentInfo; + +/// `RelayAgentInfo` crosses the Tauri boundary as the payload of +/// `list_relay_agents` and `revalidate_relay_agents`, and the frontend maps it +/// by hand: `fromRawRelayAgent` in `desktop/src/shared/api/tauri.ts` reads +/// `owner_pubkey`, `agent_type`, `channel_ids`, `respond_to` and +/// `respond_to_allowlist` and rewrites them to camelCase. +/// +/// That contract is carried entirely by this struct having no `rename_all`. +/// Adding `#[serde(rename_all = "camelCase")]` — the reflex when a Rust struct +/// feeds a TypeScript client — would leave all five fields `undefined` on the +/// other side, with no compiler and no test objecting: `RawRelayAgent` marks +/// them optional, so the mapper silently produces `null`/`[]` and every relay +/// agent turns owner-less, typeless and un-mentionable at runtime. +#[test] +fn relay_agent_info_wire_keys_are_snake_case() { + let wire = serde_json::to_value(RelayAgentInfo { + pubkey: "a".repeat(64), + owner_pubkey: Some("b".repeat(64)), + name: "Scout".to_string(), + agent_type: "agent".to_string(), + channels: vec!["general".to_string()], + channel_ids: vec!["11111111-1111-1111-1111-111111111111".to_string()], + capabilities: vec!["chat".to_string()], + status: "offline".to_string(), + respond_to: Some(RespondTo::Anyone), + respond_to_allowlist: vec!["c".repeat(64)], + }) + .expect("relay agent info serializes"); + + assert_eq!( + wire.get("owner_pubkey"), + Some(&serde_json::json!("b".repeat(64))) + ); + assert_eq!(wire.get("agent_type"), Some(&serde_json::json!("agent"))); + assert_eq!( + wire.get("channel_ids"), + Some(&serde_json::json!(["11111111-1111-1111-1111-111111111111"])) + ); + assert_eq!(wire.get("respond_to"), Some(&serde_json::json!("anyone"))); + assert_eq!( + wire.get("respond_to_allowlist"), + Some(&serde_json::json!([&"c".repeat(64)])) + ); + + for camel in [ + "ownerPubkey", + "agentType", + "channelIds", + "respondTo", + "respondToAllowlist", + ] { + assert!( + wire.get(camel).is_none(), + "{camel} must not appear on the wire: the TypeScript mapper reads the snake_case key" + ); + } +}