From 7a268470b12d6468f3502811fad01328ddb0f959 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Tue, 14 Jul 2026 03:10:04 +0200 Subject: [PATCH 1/2] feat: serialize AgentEvent and StreamDelta for wire transport AgentEvent and StreamDelta now derive Serialize, Deserialize, and PartialEq. The wire format is internally tagged camelCase JSON ({"type":"toolExecutionEnd","toolCallId":...,"isError":false}) and is a frozen public contract guarded by snapshot tests, so external frontends (websocket fanout servers, TS clients, JSONL pipes) can consume the event stream directly. Additive only: no variant, field, or signature changes. serde floor bumped to 1.0.186 for rename_all_fields. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 18 ++++ Cargo.toml | 3 +- docs/concepts/messages-events.md | 17 +++ src/types.rs | 35 +++++- tests/serialization_test.rs | 180 +++++++++++++++++++++++++++++++ 5 files changed, 250 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b5bbe8..4e6620f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to `yoagent` are documented here. The format loosely follows [Keep a Changelog](https://keepachangelog.com/), and the project adheres to [Semantic Versioning](https://semver.org/). +## Unreleased + +### Added + +- **Serializable event stream** — `AgentEvent` and `StreamDelta` now derive + `Serialize`, `Deserialize`, and `PartialEq`, so external frontends + (websocket fanout servers, TypeScript clients, JSONL pipes) can consume + the agent's event stream as JSON. The wire format is internally tagged + camelCase — `{"type":"toolExecutionEnd","toolCallId":...,"isError":false}` + — and is a **frozen public contract** guarded by snapshot tests: variant + tags, field names, and the tagging scheme will not change in minor + releases. Additive only: no variant, field, or signature changes. + +### Changed + +- `serde` minimum version is now `1.0.186` (needed for `rename_all_fields` + on the event wire format). Released August 2023; no practical impact. + ## 0.12.0 ### Added diff --git a/Cargo.toml b/Cargo.toml index 0d00076..c89392d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,8 @@ exclude = ["docs/images/*", "docs/theme/*", ".github/*", "scripts/*"] tokio = { version = "1", features = ["rt", "sync", "fs", "macros", "process", "time", "io-util"] } tokio-util = "0.7" tokio-stream = "0.1" -serde = { version = "1", features = ["derive"] } +# 1.0.186 floor: rename_all_fields on the AgentEvent wire format +serde = { version = "1.0.186", features = ["derive"] } serde_json = "1" async-trait = "0.1" thiserror = "2" diff --git a/docs/concepts/messages-events.md b/docs/concepts/messages-events.md index 60690f0..85787ab 100644 --- a/docs/concepts/messages-events.md +++ b/docs/concepts/messages-events.md @@ -133,6 +133,23 @@ Events emitted during the agent loop for real-time UI updates: | `ProgressMessage { tool_call_id, tool_name, text }` | User-facing progress text from a tool | | `InputRejected { reason }` | Input filter rejected the user's message | +### Wire format + +`AgentEvent` and `StreamDelta` serialize as internally-tagged camelCase JSON, so +external frontends (a websocket fanout server, a TypeScript client, a JSONL +pipe) can consume the event stream directly: + +```json +{"type":"messageUpdate","message":{...},"delta":{"type":"text","delta":"hi"}} +{"type":"toolExecutionEnd","toolCallId":"tc_1","toolName":"bash","result":{...},"isError":false} +``` + +This shape is a **public contract** frozen by snapshot tests — variant tags, +field names, and the tagging scheme won't change in minor releases. Note that +`MessageUpdate` carries the *cumulative* `message` alongside the incremental +`delta`, so a client that misses events (e.g. a lagged websocket subscriber) +resyncs from the next full `message` without replay. + ## StreamDelta Deltas within `MessageUpdate`: diff --git a/src/types.rs b/src/types.rs index 7275446..046cc34 100644 --- a/src/types.rs +++ b/src/types.rs @@ -518,7 +518,29 @@ pub enum ToolError { // Agent events (for streaming UI updates) // --------------------------------------------------------------------------- -#[derive(Debug, Clone)] +/// Events emitted by the agent loop for streaming UI updates. +/// +/// # Wire format (stability contract) +/// +/// `AgentEvent` and [`StreamDelta`] serialize as internally-tagged JSON — +/// `{"type": "", ...camelCase fields}` — so external +/// frontends (websocket fanout servers, TypeScript clients, JSONL pipes) can +/// consume the event stream directly: +/// +/// ```json +/// {"type":"messageUpdate","message":{...},"delta":{"type":"text","delta":"hi"}} +/// {"type":"toolExecutionEnd","toolCallId":"tc_1","toolName":"bash","result":{...},"isError":false} +/// ``` +/// +/// This shape is a **public contract**: variant tags, field names, and the +/// internal tagging are frozen by snapshot tests. Renaming a variant or field +/// is a breaking change for wire clients, not just for Rust callers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum AgentEvent { AgentStart, AgentEnd { @@ -565,7 +587,16 @@ pub enum AgentEvent { }, } -#[derive(Debug, Clone)] +/// Incremental content delta carried by [`AgentEvent::MessageUpdate`]. +/// +/// Serializes internally tagged (`{"type":"text","delta":"..."}`); see the +/// wire-format contract on [`AgentEvent`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum StreamDelta { Text { delta: String }, Thinking { delta: String }, diff --git a/tests/serialization_test.rs b/tests/serialization_test.rs index a1c3627..4910625 100644 --- a/tests/serialization_test.rs +++ b/tests/serialization_test.rs @@ -273,3 +273,183 @@ fn test_tool_call_with_metadata_roundtrip() { serde_json::json!({"thought_signature": "sig-xyz"}), )); } + +// --------------------------------------------------------------------------- +// AgentEvent wire format (public contract — see the doc comment on AgentEvent) +// --------------------------------------------------------------------------- + +fn sample_assistant() -> AgentMessage { + AgentMessage::Llm(Message::assistant( + vec![Content::Text { text: "hi".into() }], + StopReason::Stop, + "claude-sonnet", + "anthropic", + Usage::default(), + )) +} + +fn sample_tool_result() -> ToolResult { + ToolResult { + content: vec![Content::Text { + text: "exit code 0".into(), + }], + details: serde_json::json!({"exit_code": 0}), + } +} + +/// One value of every `AgentEvent` variant. +fn all_agent_events() -> Vec { + vec![ + AgentEvent::AgentStart, + AgentEvent::AgentEnd { + messages: vec![sample_assistant()], + }, + AgentEvent::TurnStart, + AgentEvent::TurnEnd { + message: sample_assistant(), + tool_results: vec![Message::ToolResult { + tool_call_id: "tc-1".into(), + tool_name: "bash".into(), + content: vec![Content::Text { text: "ok".into() }], + is_error: false, + timestamp: 7, + }], + }, + AgentEvent::MessageStart { + message: sample_assistant(), + }, + AgentEvent::MessageUpdate { + message: sample_assistant(), + delta: StreamDelta::Text { delta: "hi".into() }, + }, + AgentEvent::MessageEnd { + message: sample_assistant(), + }, + AgentEvent::ToolExecutionStart { + tool_call_id: "tc-1".into(), + tool_name: "bash".into(), + args: serde_json::json!({"command": "ls"}), + }, + AgentEvent::ToolExecutionUpdate { + tool_call_id: "tc-1".into(), + tool_name: "bash".into(), + partial_result: sample_tool_result(), + }, + AgentEvent::ToolExecutionEnd { + tool_call_id: "tc-1".into(), + tool_name: "bash".into(), + result: sample_tool_result(), + is_error: false, + }, + AgentEvent::ProgressMessage { + tool_call_id: "tc-1".into(), + tool_name: "bash".into(), + text: "50% done".into(), + }, + AgentEvent::InputRejected { + reason: "injection detected".into(), + }, + ] +} + +#[test] +fn test_agent_event_every_variant_roundtrips() { + for event in all_agent_events() { + roundtrip(&event); + } +} + +#[test] +fn test_stream_delta_every_variant_roundtrips() { + roundtrip(&StreamDelta::Text { delta: "a".into() }); + roundtrip(&StreamDelta::Thinking { delta: "b".into() }); + roundtrip(&StreamDelta::ToolCallDelta { delta: "c".into() }); +} + +/// Freezes the `"type"` discriminant of every variant. A tag change here is a +/// breaking change for wire clients — do not update this list casually. +#[test] +fn test_agent_event_type_tags_are_frozen() { + let expected = [ + "agentStart", + "agentEnd", + "turnStart", + "turnEnd", + "messageStart", + "messageUpdate", + "messageEnd", + "toolExecutionStart", + "toolExecutionUpdate", + "toolExecutionEnd", + "progressMessage", + "inputRejected", + ]; + let events = all_agent_events(); + assert_eq!(events.len(), expected.len()); + for (event, tag) in events.iter().zip(expected) { + let v: serde_json::Value = serde_json::to_value(event).expect("serialize"); + assert_eq!(v["type"], *tag, "tag drifted for {event:?}"); + } +} + +/// Shape snapshot: camelCase field names on the wire (`rename_all_fields`). +#[test] +fn test_agent_event_fields_are_camel_case() { + let end = AgentEvent::ToolExecutionEnd { + tool_call_id: "tc-1".into(), + tool_name: "bash".into(), + result: sample_tool_result(), + is_error: true, + }; + let v = serde_json::to_value(&end).expect("serialize"); + assert_eq!(v["toolCallId"], "tc-1"); + assert_eq!(v["toolName"], "bash"); + assert_eq!(v["isError"], true); + assert!( + v.get("tool_call_id").is_none(), + "snake_case leaked onto the wire" + ); + + let update = AgentEvent::MessageUpdate { + message: sample_assistant(), + delta: StreamDelta::Text { delta: "hi".into() }, + }; + let v = serde_json::to_value(&update).expect("serialize"); + assert_eq!(v["type"], "messageUpdate"); + assert_eq!(v["delta"]["type"], "text"); + assert_eq!(v["delta"]["delta"], "hi"); + assert!(v.get("message").is_some()); + + let rejected = AgentEvent::InputRejected { + reason: "nope".into(), + }; + let v = serde_json::to_value(&rejected).expect("serialize"); + assert_eq!(v["reason"], "nope"); +} + +/// Unit variants carry only the tag: `{"type":"agentStart"}`. +#[test] +fn test_agent_event_unit_variant_shape() { + let json = serde_json::to_string(&AgentEvent::AgentStart).expect("serialize"); + assert_eq!(json, r#"{"type":"agentStart"}"#); + let json = serde_json::to_string(&AgentEvent::TurnStart).expect("serialize"); + assert_eq!(json, r#"{"type":"turnStart"}"#); +} + +/// A wire client's inbound path: parse an event from a raw JSON line. +#[test] +fn test_agent_event_deserializes_from_raw_json() { + let line = r#"{"type":"toolExecutionStart","toolCallId":"tc-9","toolName":"read","args":{"path":"a.rs"}}"#; + let event: AgentEvent = serde_json::from_str(line).expect("deserialize"); + let AgentEvent::ToolExecutionStart { + tool_call_id, + tool_name, + args, + } = event + else { + panic!("expected ToolExecutionStart"); + }; + assert_eq!(tool_call_id, "tc-9"); + assert_eq!(tool_name, "read"); + assert_eq!(args["path"], "a.rs"); +} From 6bae21377b0948af50ba08b7d0603efdba91120b Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Tue, 14 Jul 2026 03:20:37 +0200 Subject: [PATCH 2/2] fix: freeze nested payload shape, close review gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings from PR #72: - Normalize the five remaining snake_case payload fields to camelCase (usage.cacheRead/cacheWrite/totalTokens, errorMessage, providerMetadata) with serde aliases so pre-0.13 session files still load. The wire contract is now uniformly camelCase. - Freeze tags via exhaustive match (no wildcard): a new AgentEvent or StreamDelta variant now fails to compile in the test file until its wire tag is pinned. - Exact-JSON snapshot of a full messageEnd payload (all Content variants, non-default Usage, errorMessage) — freezes the nested shape that carries most wire bytes. - Forward-compat tests: unknown fields ignored, unknown/wrong-case tags are clean Errs; legacy snake_case payload load test. - Fix docs: MessageUpdate.message is a placeholder during streaming; clients accumulate deltas and resync at MessageEnd (the cumulative claim was false). - serde floor corrected to 1.0.181 (actual rename_all_fields release). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 12 +- Cargo.toml | 4 +- docs/concepts/messages-events.md | 11 +- src/types.rs | 21 ++- tests/serialization_test.rs | 213 +++++++++++++++++++++++++++---- 5 files changed, 224 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6620f..85f1da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,16 @@ adheres to [Semantic Versioning](https://semver.org/). ### Changed -- `serde` minimum version is now `1.0.186` (needed for `rename_all_fields` - on the event wire format). Released August 2023; no practical impact. +- **Message payload serialization normalized to camelCase** — the five + remaining snake_case fields in serialized messages now match the rest of + the wire format: `usage.cacheRead`/`cacheWrite`/`totalTokens`, + `errorMessage`, and `providerMetadata`. Session files and `save_messages` + blobs written by older versions still load (`serde` aliases accept the old + names); files **written** by 0.13 use the new names, so they are not + readable by yoagent < 0.13. The full nested payload shape (message, + content blocks, usage) is now frozen by an exact-JSON snapshot test. +- `serde` minimum version is now `1.0.181` (needed for `rename_all_fields` + on the event wire format). Released July 2023; no practical impact. ## 0.12.0 diff --git a/Cargo.toml b/Cargo.toml index c89392d..50b36bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,8 +18,8 @@ exclude = ["docs/images/*", "docs/theme/*", ".github/*", "scripts/*"] tokio = { version = "1", features = ["rt", "sync", "fs", "macros", "process", "time", "io-util"] } tokio-util = "0.7" tokio-stream = "0.1" -# 1.0.186 floor: rename_all_fields on the AgentEvent wire format -serde = { version = "1.0.186", features = ["derive"] } +# 1.0.181 floor: rename_all_fields on the AgentEvent wire format +serde = { version = "1.0.181", features = ["derive"] } serde_json = "1" async-trait = "0.1" thiserror = "2" diff --git a/docs/concepts/messages-events.md b/docs/concepts/messages-events.md index 85787ab..f58057d 100644 --- a/docs/concepts/messages-events.md +++ b/docs/concepts/messages-events.md @@ -145,10 +145,13 @@ pipe) can consume the event stream directly: ``` This shape is a **public contract** frozen by snapshot tests — variant tags, -field names, and the tagging scheme won't change in minor releases. Note that -`MessageUpdate` carries the *cumulative* `message` alongside the incremental -`delta`, so a client that misses events (e.g. a lagged websocket subscriber) -resyncs from the next full `message` without replay. +field names, and the tagging scheme won't change in minor releases. + +Streaming semantics: clients accumulate text from each `MessageUpdate`'s +`delta`; the `message` field during streaming is a placeholder (its `content` +fills in only when the message completes). The full message arrives with +`MessageEnd` — a client that misses events (e.g. a lagged websocket +subscriber) resyncs from the next `MessageEnd` without replay. ## StreamDelta diff --git a/src/types.rs b/src/types.rs index 046cc34..0ab77b6 100644 --- a/src/types.rs +++ b/src/types.rs @@ -45,7 +45,12 @@ pub enum Content { /// Provider-specific metadata (e.g. Gemini thought signatures). /// Not passed to tool execution; used by providers when building /// the next request. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "providerMetadata", + alias = "provider_metadata" + )] provider_metadata: Option, }, } @@ -124,7 +129,11 @@ pub enum Message { provider: String, usage: Usage, timestamp: u64, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + rename = "errorMessage", + alias = "error_message" + )] error_message: Option, }, #[serde(rename = "toolResult")] @@ -300,11 +309,13 @@ pub enum StopReason { pub struct Usage { pub input: u64, pub output: u64, - #[serde(default)] + // camelCase on the wire (AgentEvent contract); `alias` keeps session + // files written by yoagent < 0.13 loadable. + #[serde(default, rename = "cacheRead", alias = "cache_read")] pub cache_read: u64, - #[serde(default)] + #[serde(default, rename = "cacheWrite", alias = "cache_write")] pub cache_write: u64, - #[serde(default)] + #[serde(default, rename = "totalTokens", alias = "total_tokens")] pub total_tokens: u64, } diff --git a/tests/serialization_test.rs b/tests/serialization_test.rs index 4910625..1a82c0b 100644 --- a/tests/serialization_test.rs +++ b/tests/serialization_test.rs @@ -359,39 +359,204 @@ fn test_agent_event_every_variant_roundtrips() { } } +fn all_stream_deltas() -> Vec { + vec![ + StreamDelta::Text { delta: "a".into() }, + StreamDelta::Thinking { delta: "b".into() }, + StreamDelta::ToolCallDelta { delta: "c".into() }, + ] +} + #[test] fn test_stream_delta_every_variant_roundtrips() { - roundtrip(&StreamDelta::Text { delta: "a".into() }); - roundtrip(&StreamDelta::Thinking { delta: "b".into() }); - roundtrip(&StreamDelta::ToolCallDelta { delta: "c".into() }); + for delta in all_stream_deltas() { + roundtrip(&delta); + } +} + +/// The frozen `"type"` tag for every `AgentEvent` variant. Exhaustive match +/// with NO wildcard arm on purpose: adding a variant fails to compile here +/// until its wire tag is pinned (and a sample added to `all_agent_events`). +/// A tag change is a breaking change for wire clients — do not edit casually. +fn expected_event_tag(event: &AgentEvent) -> &'static str { + match event { + AgentEvent::AgentStart => "agentStart", + AgentEvent::AgentEnd { .. } => "agentEnd", + AgentEvent::TurnStart => "turnStart", + AgentEvent::TurnEnd { .. } => "turnEnd", + AgentEvent::MessageStart { .. } => "messageStart", + AgentEvent::MessageUpdate { .. } => "messageUpdate", + AgentEvent::MessageEnd { .. } => "messageEnd", + AgentEvent::ToolExecutionStart { .. } => "toolExecutionStart", + AgentEvent::ToolExecutionUpdate { .. } => "toolExecutionUpdate", + AgentEvent::ToolExecutionEnd { .. } => "toolExecutionEnd", + AgentEvent::ProgressMessage { .. } => "progressMessage", + AgentEvent::InputRejected { .. } => "inputRejected", + } +} + +/// Same exhaustive-match freeze for `StreamDelta` tags. +fn expected_delta_tag(delta: &StreamDelta) -> &'static str { + match delta { + StreamDelta::Text { .. } => "text", + StreamDelta::Thinking { .. } => "thinking", + StreamDelta::ToolCallDelta { .. } => "toolCallDelta", + } } -/// Freezes the `"type"` discriminant of every variant. A tag change here is a -/// breaking change for wire clients — do not update this list casually. #[test] fn test_agent_event_type_tags_are_frozen() { - let expected = [ - "agentStart", - "agentEnd", - "turnStart", - "turnEnd", - "messageStart", - "messageUpdate", - "messageEnd", - "toolExecutionStart", - "toolExecutionUpdate", - "toolExecutionEnd", - "progressMessage", - "inputRejected", - ]; - let events = all_agent_events(); - assert_eq!(events.len(), expected.len()); - for (event, tag) in events.iter().zip(expected) { - let v: serde_json::Value = serde_json::to_value(event).expect("serialize"); - assert_eq!(v["type"], *tag, "tag drifted for {event:?}"); + for event in all_agent_events() { + let v: serde_json::Value = serde_json::to_value(&event).expect("serialize"); + assert_eq!( + v["type"], + expected_event_tag(&event), + "tag drifted for {event:?}" + ); + } +} + +#[test] +fn test_stream_delta_type_tags_are_frozen() { + for delta in all_stream_deltas() { + let v: serde_json::Value = serde_json::to_value(&delta).expect("serialize"); + assert_eq!( + v["type"], + expected_delta_tag(&delta), + "tag drifted for {delta:?}" + ); } } +/// Freezes the FULL nested payload shape — most of the wire bytes in a real +/// stream are the `message` payload, so the contract extends to `Message`, +/// `Content`, and `Usage` serialization. This is the one test comparing +/// against a complete JSON literal: any serde-attribute change on those types +/// shows up here as a client-visible wire break. +#[test] +fn test_message_end_full_payload_shape_is_frozen() { + let event = AgentEvent::MessageEnd { + message: AgentMessage::Llm( + Message::assistant( + vec![ + Content::Text { text: "hi".into() }, + Content::Image { + data: "aGk=".into(), + mime_type: "image/png".into(), + }, + Content::thinking_signed("hmm", "sig-1"), + Content::tool_call_with_metadata( + "tc-1", + "bash", + serde_json::json!({"command": "ls"}), + serde_json::json!({"thought_signature": "sig-2"}), + ), + ], + StopReason::ToolUse, + "claude-sonnet", + "anthropic", + Usage { + input: 100, + output: 50, + cache_read: 10, + cache_write: 5, + total_tokens: 165, + }, + ) + .with_timestamp(1234) + .with_error_message("boom"), + ), + }; + let expected = serde_json::json!({ + "type": "messageEnd", + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "hi"}, + {"type": "image", "data": "aGk=", "mimeType": "image/png"}, + {"type": "thinking", "thinking": "hmm", "signature": "sig-1"}, + {"type": "toolCall", "id": "tc-1", "name": "bash", + "arguments": {"command": "ls"}, + "providerMetadata": {"thought_signature": "sig-2"}}, + ], + "stopReason": "toolUse", + "model": "claude-sonnet", + "provider": "anthropic", + "usage": { + "input": 100, + "output": 50, + "cacheRead": 10, + "cacheWrite": 5, + "totalTokens": 165, + }, + "timestamp": 1234, + "errorMessage": "boom", + }, + }); + let actual = serde_json::to_value(&event).expect("serialize"); + assert_eq!(actual, expected, "nested payload wire shape drifted"); +} + +/// Session files and `save_messages` blobs written by yoagent < 0.13 used +/// snake_case for `cache_read`/`cache_write`/`total_tokens`/`error_message`/ +/// `provider_metadata`. The `alias` attributes must keep them loadable. +#[test] +fn test_legacy_snake_case_payload_still_deserializes() { + let legacy = r#"{ + "role": "assistant", + "content": [ + {"type": "toolCall", "id": "tc-1", "name": "bash", + "arguments": {}, "provider_metadata": {"sig": "x"}} + ], + "stopReason": "stop", + "model": "m", + "provider": "p", + "usage": {"input": 1, "output": 2, "cache_read": 3, + "cache_write": 4, "total_tokens": 10}, + "timestamp": 1, + "error_message": "old" + }"#; + let msg: Message = serde_json::from_str(legacy).expect("legacy payload must load"); + let Message::Assistant { + usage, + error_message, + content, + .. + } = &msg + else { + panic!("expected assistant"); + }; + assert_eq!(usage.cache_read, 3); + assert_eq!(usage.cache_write, 4); + assert_eq!(usage.total_tokens, 10); + assert_eq!(error_message.as_deref(), Some("old")); + let Content::ToolCall { + provider_metadata, .. + } = &content[0] + else { + panic!("expected toolCall"); + }; + assert_eq!(provider_metadata.as_ref().unwrap()["sig"], "x"); +} + +/// Forward compatibility for wire clients: unknown fields inside a known +/// event are ignored (additive evolution), while an unknown or wrong-cased +/// `"type"` tag is a clean `Err`, never a panic. +#[test] +fn test_agent_event_forward_compat_deserialization() { + // Unknown extra field → ignored. + let line = r#"{"type":"inputRejected","reason":"x","newField":1}"#; + let event: AgentEvent = serde_json::from_str(line).expect("unknown fields are ignored"); + assert!(matches!(event, AgentEvent::InputRejected { .. })); + + // Unknown tag → Err. + assert!(serde_json::from_str::(r#"{"type":"compactionStart"}"#).is_err()); + // Wrong casing → Err (tags are case-sensitive). + assert!( + serde_json::from_str::(r#"{"type":"InputRejected","reason":"x"}"#).is_err() + ); +} + /// Shape snapshot: camelCase field names on the wire (`rename_all_fields`). #[test] fn test_agent_event_fields_are_camel_case() {