Skip to main content

shore_protocol/
lib.rs

1pub mod client_msg;
2pub mod error;
3pub mod merge;
4pub mod server_msg;
5pub mod tool_display;
6pub mod types;
7
8/// SWP protocol version.
9pub const SWP_V1: u32 = 1;
10
11/// Maximum newline-delimited SWP frame size in bytes.
12///
13/// Sized to accommodate image attachments after base64 expansion (~33% over
14/// the raw byte size) plus headroom for history snapshots that include
15/// multiple inline images. A 16MB cap, the previous value, was tight enough
16/// that a single ~12MB phone photo encoded to base64 would exceed it and the
17/// server would terminate the connection mid-upload with "Message exceeds
18/// maximum size". 128MB gives plenty of margin for any practical chat-image
19/// workload while still bounding worst-case memory use per frame.
20pub const MAX_WIRE_MESSAGE_SIZE: usize = 128 * 1024 * 1024;
21
22#[cfg(test)]
23mod tests {
24    use serde_json::json;
25
26    use crate::client_msg::*;
27    use crate::error::*;
28    use crate::server_msg::*;
29    use crate::types::*;
30    use crate::{MAX_WIRE_MESSAGE_SIZE, SWP_V1};
31
32    /// Helper: serialize then deserialize, return the intermediate JSON.
33    fn round_trip<T: serde::Serialize + serde::de::DeserializeOwned + std::fmt::Debug>(
34        val: &T,
35    ) -> (serde_json::Value, T) {
36        let json = serde_json::to_value(val).expect("serialize");
37        let back: T = serde_json::from_value(json.clone()).expect("deserialize");
38        (json, back)
39    }
40
41    // ── Protocol version ──────────────────────────────────────────────
42
43    #[test]
44    fn protocol_version_constant() {
45        assert_eq!(SWP_V1, 1);
46    }
47
48    #[test]
49    fn wire_message_size_constant() {
50        assert_eq!(MAX_WIRE_MESSAGE_SIZE, 128 * 1024 * 1024);
51    }
52
53    // ── Client messages ───────────────────────────────────────────────
54
55    #[test]
56    fn client_hello_round_trip() {
57        let msg = ClientMessage::Hello(ClientHello {
58            client_type: "tui".into(),
59            client_name: "shore-tui".into(),
60            capabilities: vec!["streaming".into()],
61            character: None,
62        });
63        let (json, _back) = round_trip(&msg);
64        assert_eq!(json["type"], "hello");
65        assert_eq!(json["client_type"], "tui");
66    }
67
68    #[test]
69    fn client_message_round_trip() {
70        let msg = ClientMessage::Message(ClientMessageBody {
71            rid: Some("msg_01".into()),
72            text: "Hello world".into(),
73            stream: true,
74            images: vec![],
75            image_data: vec![],
76            absence_seconds: None,
77            overrides: None,
78        });
79        let (json, _back) = round_trip(&msg);
80        assert_eq!(json["type"], "message");
81        assert_eq!(json["text"], "Hello world");
82        assert_eq!(json["stream"], true);
83    }
84
85    #[test]
86    fn client_regen_round_trip() {
87        let msg = ClientMessage::Regen(Regen {
88            rid: Some("regen_01".into()),
89            stream: true,
90            guidance: None,
91        });
92        let (json, _back) = round_trip(&msg);
93        assert_eq!(json["type"], "regen");
94    }
95
96    #[test]
97    fn client_command_round_trip() {
98        let msg = ClientMessage::Command(Command {
99            rid: Some("cmd_01".into()),
100            name: "switch_character".into(),
101            args: json!({"name": "alice"}),
102        });
103        let (json, _back) = round_trip(&msg);
104        assert_eq!(json["type"], "command");
105        assert_eq!(json["name"], "switch_character");
106        assert_eq!(json["args"]["name"], "alice");
107    }
108
109    // ── Server messages ───────────────────────────────────────────────
110
111    #[test]
112    fn server_hello_round_trip() {
113        let msg = ServerMessage::Hello(ServerHello {
114            v: SWP_V1,
115            server_name: "shore-daemon".into(),
116            characters: vec![CharacterInfo::new("alice")],
117        });
118        let (json, _back) = round_trip(&msg);
119        assert_eq!(json["type"], "hello");
120        assert_eq!(json["v"], 1);
121    }
122
123    #[test]
124    fn server_history_round_trip() {
125        let msg = ServerMessage::History(History {
126            rid: None,
127            messages: vec![Message {
128                msg_id: "m1".into(),
129                role: Role::User,
130                content: "hi".into(),
131                images: vec![],
132                content_blocks: vec![],
133                alt_index: None,
134                alt_count: None,
135                alternatives: vec![],
136                timestamp: "2026-01-01T00:00:00Z".into(),
137            }],
138            active_start: 0,
139            config: json!({}),
140            selected_character: Some("alice".into()),
141            revision: 7,
142        });
143        let (json, _back) = round_trip(&msg);
144        assert_eq!(json["type"], "history");
145        assert_eq!(json["messages"][0]["role"], "user");
146        assert_eq!(json["selected_character"], "alice");
147        assert_eq!(json["revision"], 7);
148    }
149
150    #[test]
151    fn server_request_history_round_trip() {
152        let msg = ServerMessage::History(History {
153            rid: Some("cmd_switch_01".into()),
154            messages: vec![],
155            active_start: 0,
156            config: json!({}),
157            selected_character: Some("alice".into()),
158            revision: 8,
159        });
160        let (json, _back) = round_trip(&msg);
161        assert_eq!(json["type"], "history");
162        assert_eq!(json["rid"], "cmd_switch_01");
163        assert_eq!(json["revision"], 8);
164    }
165
166    #[test]
167    fn server_shutdown_round_trip() {
168        let msg = ServerMessage::Shutdown(Shutdown {});
169        let (json, _back) = round_trip(&msg);
170        assert_eq!(json["type"], "shutdown");
171    }
172
173    #[test]
174    fn server_ping_round_trip() {
175        let msg = ServerMessage::Ping(Ping {});
176        let (json, _back) = round_trip(&msg);
177        assert_eq!(json["type"], "ping");
178    }
179
180    #[test]
181    fn server_command_output_round_trip() {
182        let msg = ServerMessage::CommandOutput(CommandOutput {
183            rid: Some("cmd_01".into()),
184            name: "status".into(),
185            data: json!({"ok": true}),
186        });
187        let (json, _back) = round_trip(&msg);
188        assert_eq!(json["type"], "command_output");
189        assert_eq!(json["rid"], "cmd_01");
190        assert_eq!(json["name"], "status");
191    }
192
193    #[test]
194    fn server_error_round_trip() {
195        let msg = ServerMessage::Error(Error {
196            rid: Some("msg_01".into()),
197            code: ErrorCode::Busy,
198            message: "engine busy".into(),
199        });
200        let (json, _back) = round_trip(&msg);
201        assert_eq!(json["type"], "error");
202        assert_eq!(json["rid"], "msg_01");
203        assert_eq!(json["code"], "busy");
204    }
205
206    #[test]
207    fn server_stream_start_round_trip() {
208        let msg = ServerMessage::StreamStart(StreamStart {
209            rid: Some("msg_01".into()),
210            regen: false,
211        });
212        let (json, _back) = round_trip(&msg);
213        assert_eq!(json["type"], "stream_start");
214        assert_eq!(json["rid"], "msg_01");
215        assert_eq!(json["regen"], false);
216    }
217
218    #[test]
219    fn server_stream_chunk_round_trip() {
220        let msg = ServerMessage::StreamChunk(StreamChunk {
221            rid: Some("msg_01".into()),
222            text: "partial".into(),
223            content_type: "text".into(),
224        });
225        let (json, _back) = round_trip(&msg);
226        assert_eq!(json["type"], "stream_chunk");
227        assert_eq!(json["rid"], "msg_01");
228        assert_eq!(json["content_type"], "text");
229    }
230
231    #[test]
232    fn server_stream_chunk_thinking() {
233        let msg = ServerMessage::StreamChunk(StreamChunk {
234            rid: Some("msg_01".into()),
235            text: "hmm...".into(),
236            content_type: "thinking".into(),
237        });
238        let (json, _back) = round_trip(&msg);
239        assert_eq!(json["rid"], "msg_01");
240        assert_eq!(json["content_type"], "thinking");
241    }
242
243    #[test]
244    fn server_stream_end_round_trip() {
245        let msg = ServerMessage::StreamEnd(StreamEnd {
246            rid: Some("msg_01".into()),
247            msg_id: None,
248            revision: None,
249            content: "full response".into(),
250            metadata: StreamMetadata {
251                tokens: TokenCounts {
252                    input: 1234,
253                    output: 567,
254                    cache_read: 890,
255                    cache_write: 0,
256                },
257                timing: TimingInfo {
258                    total_ms: 2340,
259                    ttft_ms: 450,
260                },
261                model: "claude-haiku-4-5-20251001".into(),
262            },
263            finish_reason: "end_turn".into(),
264            is_final: true,
265        });
266        let (json, _back) = round_trip(&msg);
267        assert_eq!(json["type"], "stream_end");
268        assert_eq!(json["rid"], "msg_01");
269        assert!(json.get("msg_id").is_none());
270        assert!(json.get("revision").is_none());
271        assert_eq!(json["metadata"]["tokens"]["input"], 1234);
272        assert_eq!(json["metadata"]["tokens"]["cache_read"], 890);
273        assert_eq!(json["metadata"]["timing"]["total_ms"], 2340);
274        assert_eq!(json["metadata"]["timing"]["ttft_ms"], 450);
275        assert_eq!(json["metadata"]["model"], "claude-haiku-4-5-20251001");
276    }
277
278    #[test]
279    fn server_phase_round_trip() {
280        for phase_val in &["thinking", "text_generation", "tool_use"] {
281            let msg = ServerMessage::Phase(Phase {
282                rid: Some("msg_01".into()),
283                phase: phase_val.to_string(),
284                model: Some("test-model".into()),
285            });
286            let (json, _back) = round_trip(&msg);
287            assert_eq!(json["type"], "phase");
288            assert_eq!(json["rid"], "msg_01");
289            assert_eq!(json["phase"], *phase_val);
290        }
291    }
292
293    #[test]
294    fn server_new_message_round_trip() {
295        let msg = ServerMessage::NewMessage(NewMessage {
296            revision: 3,
297            character: Some("Alice".into()),
298            origin: Some(MessageOrigin::Autonomous),
299            message: Message {
300                msg_id: "m2".into(),
301                role: Role::Assistant,
302                content: "autonomous msg".into(),
303                images: vec![],
304                content_blocks: vec![],
305                alt_index: None,
306                alt_count: None,
307                alternatives: vec![],
308                timestamp: "2026-01-01T00:00:01Z".into(),
309            },
310        });
311        let (json, _back) = round_trip(&msg);
312        assert_eq!(json["type"], "new_message");
313        assert_eq!(json["character"], "Alice");
314        assert_eq!(json["origin"], "autonomous");
315        assert_eq!(json["msg_id"], "m2");
316        assert_eq!(json["revision"], 3);
317    }
318
319    #[test]
320    fn server_tool_call_round_trip() {
321        let msg = ServerMessage::ToolCall(ToolCall {
322            rid: Some("msg_01".into()),
323            tool_id: "t1".into(),
324            tool_name: "search".into(),
325            input: json!({"query": "rust serde"}),
326        });
327        let (json, _back) = round_trip(&msg);
328        assert_eq!(json["type"], "tool_call");
329        assert_eq!(json["rid"], "msg_01");
330        assert_eq!(json["input"]["query"], "rust serde");
331        // Verify input is a JSON object, not a string
332        assert!(json["input"].is_object());
333    }
334
335    #[test]
336    fn server_tool_result_round_trip() {
337        let msg = ServerMessage::ToolResult(ToolResult {
338            rid: Some("msg_01".into()),
339            tool_id: "t1".into(),
340            tool_name: "search".into(),
341            output: "found 5 results".into(),
342            is_error: false,
343        });
344        let (json, _back) = round_trip(&msg);
345        assert_eq!(json["type"], "tool_result");
346        assert_eq!(json["rid"], "msg_01");
347    }
348
349    #[test]
350    fn server_send_image_round_trip() {
351        let msg = ServerMessage::SendImage(SendImage {
352            rid: Some("msg_01".into()),
353            path: "/tmp/img.png".into(),
354            caption: Some("generated chart".into()),
355            data: None,
356        });
357        let (json, _back) = round_trip(&msg);
358        assert_eq!(json["type"], "send_image");
359        assert_eq!(json["rid"], "msg_01");
360        assert_eq!(json["path"], "/tmp/img.png");
361        assert_eq!(json["caption"], "generated chart");
362    }
363
364    #[test]
365    fn server_cache_warning_round_trip() {
366        let msg = ServerMessage::CacheWarning(CacheWarning {
367            expected_tokens: 5000,
368            message: "cache miss".into(),
369        });
370        let (json, _back) = round_trip(&msg);
371        assert_eq!(json["type"], "cache_warning");
372        assert_eq!(json["expected_tokens"], 5000);
373    }
374
375    #[test]
376    fn server_usage_warning_round_trip() {
377        let msg = ServerMessage::UsageWarning(UsageWarning {
378            rid: Some("msg_01".into()),
379            budget: "daily total".into(),
380            message: "Usage budget \"daily total\" reached 80% ($8.00/$10.00).".into(),
381            current_cost: 8.0,
382            cost_limit: 10.0,
383            percent_used: 0.8,
384            crossed_warn_at: vec![0.8],
385            period: "day".into(),
386            period_start: "2026-05-18T00:00:00Z".into(),
387            reset_at: "2026-05-19T00:00:00Z".into(),
388        });
389        let (json, _back) = round_trip(&msg);
390        assert_eq!(json["type"], "usage_warning");
391        assert_eq!(json["rid"], "msg_01");
392        assert_eq!(json["budget"], "daily total");
393    }
394
395    // ── Types ─────────────────────────────────────────────────────────
396
397    #[test]
398    fn message_with_all_fields() {
399        let msg = Message {
400            msg_id: "m3".into(),
401            role: Role::Assistant,
402            content: "response".into(),
403            images: vec![ImageRef {
404                path: "/img/a.png".into(),
405                caption: Some("photo".into()),
406                data: None,
407            }],
408            content_blocks: vec![],
409            alt_index: Some(0),
410            alt_count: Some(1),
411            alternatives: vec![MessageAlternative {
412                content: "response".into(),
413                images: vec![],
414                content_blocks: vec![ContentBlock::Text {
415                    text: "response".into(),
416                }],
417                timestamp: "2026-01-01T00:00:00Z".into(),
418            }],
419            timestamp: "2026-01-01T00:00:00Z".into(),
420        };
421        let (json, back) = round_trip(&msg);
422        assert_eq!(json["alt_index"], 0);
423        assert_eq!(json["alt_count"], 1);
424        assert_eq!(json["alternatives"][0]["content"], "response");
425        assert_eq!(json["images"][0]["path"], "/img/a.png");
426        assert_eq!(back.alt_index, Some(0));
427        assert_eq!(back.alt_count, Some(1));
428        assert_eq!(back.alternatives.len(), 1);
429    }
430
431    #[test]
432    fn message_without_alts_omits_fields() {
433        let msg = Message {
434            msg_id: "m4".into(),
435            role: Role::User,
436            content: "hi".into(),
437            images: vec![],
438            content_blocks: vec![],
439            alt_index: None,
440            alt_count: None,
441            alternatives: vec![],
442            timestamp: "2026-01-01T00:00:00Z".into(),
443        };
444        let json = serde_json::to_value(&msg).unwrap();
445        assert!(json.get("alt_index").is_none());
446        assert!(json.get("alt_count").is_none());
447        assert!(json.get("alternatives").is_none());
448    }
449
450    #[test]
451    fn stream_metadata_nested_structure() {
452        let meta = StreamMetadata {
453            tokens: TokenCounts {
454                input: 100,
455                output: 50,
456                cache_read: 0,
457                cache_write: 0,
458            },
459            timing: TimingInfo {
460                total_ms: 1000,
461                ttft_ms: 200,
462            },
463            model: "test".into(),
464        };
465        let (json, _back) = round_trip(&meta);
466        assert!(json["tokens"].is_object());
467        assert!(json["timing"].is_object());
468        assert_eq!(json["tokens"]["input"], 100);
469        assert_eq!(json["timing"]["ttft_ms"], 200);
470    }
471
472    #[test]
473    fn error_code_all_variants() {
474        let codes = [
475            ErrorCode::ProtocolError,
476            ErrorCode::InvalidRequest,
477            ErrorCode::NotFound,
478            ErrorCode::Busy,
479            ErrorCode::ProviderError,
480            ErrorCode::Timeout,
481            ErrorCode::InternalError,
482        ];
483        let expected = [
484            "protocol_error",
485            "invalid_request",
486            "not_found",
487            "busy",
488            "provider_error",
489            "timeout",
490            "internal_error",
491        ];
492        for (code, exp) in codes.iter().zip(expected.iter()) {
493            let json = serde_json::to_value(code).unwrap();
494            assert_eq!(json.as_str().unwrap(), *exp);
495        }
496    }
497
498    #[test]
499    fn character_info_round_trip() {
500        let info = CharacterInfo::new("alice");
501        let (json, back) = round_trip(&info);
502        assert!(json.get("avatar").is_none());
503        assert_eq!(back.name, "alice");
504        assert_eq!(back.avatar, None);
505    }
506
507    #[test]
508    fn character_info_avatar_round_trip() {
509        let info = CharacterInfo {
510            name: "alice".into(),
511            avatar: Some(crate::types::CharacterAvatar {
512                mime_type: "image/png".into(),
513                data: "AQID".into(),
514            }),
515        };
516        let (json, back) = round_trip(&info);
517        assert_eq!(json["avatar"]["mime_type"], "image/png");
518        assert_eq!(back.avatar.unwrap().data, "AQID");
519    }
520
521    #[test]
522    fn role_serialization() {
523        assert_eq!(serde_json::to_value(Role::User).unwrap(), "user");
524        assert_eq!(serde_json::to_value(Role::Assistant).unwrap(), "assistant");
525        assert_eq!(serde_json::to_value(Role::System).unwrap(), "system");
526    }
527
528    // ── ContentBlock serde round-trip ─────────────────────────────────
529
530    #[test]
531    fn content_block_text_round_trip() {
532        let block = ContentBlock::Text {
533            text: "hello world".into(),
534        };
535        let json = serde_json::to_value(&block).unwrap();
536        assert_eq!(json["type"], "text");
537        assert_eq!(json["text"], "hello world");
538        let back: ContentBlock = serde_json::from_value(json).unwrap();
539        assert_eq!(back, block);
540    }
541
542    #[test]
543    fn content_block_thinking_round_trip() {
544        let block = ContentBlock::Thinking {
545            thinking: "Let me consider...".into(),
546            signature: None,
547        };
548        let json = serde_json::to_value(&block).unwrap();
549        assert_eq!(json["type"], "thinking");
550        assert_eq!(json["thinking"], "Let me consider...");
551        let back: ContentBlock = serde_json::from_value(json).unwrap();
552        assert_eq!(back, block);
553    }
554
555    #[test]
556    fn content_block_thinking_with_signature_round_trip() {
557        let block = ContentBlock::Thinking {
558            thinking: "Let me consider...".into(),
559            signature: Some("sig_abc123".into()),
560        };
561        let json = serde_json::to_value(&block).unwrap();
562        assert_eq!(json["type"], "thinking");
563        assert_eq!(json["thinking"], "Let me consider...");
564        assert_eq!(json["signature"], "sig_abc123");
565        let back: ContentBlock = serde_json::from_value(json).unwrap();
566        assert_eq!(back, block);
567    }
568
569    #[test]
570    fn content_block_redacted_thinking_round_trip() {
571        let block = ContentBlock::RedactedThinking {
572            data: "opaque_data_abc".into(),
573        };
574        let json = serde_json::to_value(&block).unwrap();
575        assert_eq!(json["type"], "redacted_thinking");
576        assert_eq!(json["data"], "opaque_data_abc");
577        let back: ContentBlock = serde_json::from_value(json).unwrap();
578        assert_eq!(back, block);
579    }
580
581    #[test]
582    fn content_block_thinking_without_signature_compat() {
583        // Simulate old JSON without signature field — should deserialize with None.
584        let json = json!({"type": "thinking", "thinking": "old block"});
585        let block: ContentBlock = serde_json::from_value(json).unwrap();
586        match block {
587            ContentBlock::Thinking {
588                thinking,
589                signature,
590            } => {
591                assert_eq!(thinking, "old block");
592                assert!(signature.is_none());
593            }
594            _ => panic!("Expected Thinking"),
595        }
596    }
597
598    #[test]
599    fn content_block_tool_use_round_trip() {
600        let block = ContentBlock::ToolUse {
601            id: "tu_123".into(),
602            name: "check_time".into(),
603            input: json!({"timezone": "UTC"}),
604        };
605        let json = serde_json::to_value(&block).unwrap();
606        assert_eq!(json["type"], "tool_use");
607        assert_eq!(json["id"], "tu_123");
608        assert_eq!(json["name"], "check_time");
609        assert_eq!(json["input"]["timezone"], "UTC");
610        let back: ContentBlock = serde_json::from_value(json).unwrap();
611        assert_eq!(back, block);
612    }
613
614    #[test]
615    fn content_block_tool_result_round_trip() {
616        let block = ContentBlock::ToolResult {
617            tool_use_id: "tu_123".into(),
618            content: "2026-03-27T12:00:00Z".into(),
619            is_error: false,
620        };
621        let json = serde_json::to_value(&block).unwrap();
622        assert_eq!(json["type"], "tool_result");
623        assert_eq!(json["tool_use_id"], "tu_123");
624        assert_eq!(json["content"], "2026-03-27T12:00:00Z");
625        // is_error defaults to false, verify it round-trips
626        let back: ContentBlock = serde_json::from_value(json).unwrap();
627        assert_eq!(back, block);
628    }
629
630    #[test]
631    fn content_block_tool_result_with_error() {
632        let block = ContentBlock::ToolResult {
633            tool_use_id: "tu_456".into(),
634            content: "Tool not found".into(),
635            is_error: true,
636        };
637        let json = serde_json::to_value(&block).unwrap();
638        assert_eq!(json["is_error"], true);
639        let back: ContentBlock = serde_json::from_value(json).unwrap();
640        assert_eq!(back, block);
641    }
642
643    #[test]
644    fn content_block_tool_result_is_error_defaults_false() {
645        // Simulate old JSON without is_error field
646        let json = json!({"type": "tool_result", "tool_use_id": "tu_1", "content": "ok"});
647        let block: ContentBlock = serde_json::from_value(json).unwrap();
648        match block {
649            ContentBlock::ToolResult { is_error, .. } => assert!(!is_error),
650            _ => panic!("Expected ToolResult"),
651        }
652    }
653
654    #[test]
655    fn message_with_content_blocks_round_trip() {
656        let msg = Message {
657            msg_id: "m_test".into(),
658            role: Role::Assistant,
659            content: "The time is noon.".into(),
660            images: vec![],
661            content_blocks: vec![
662                ContentBlock::Thinking {
663                    thinking: "User wants the time.".into(),
664                    signature: None,
665                },
666                ContentBlock::ToolUse {
667                    id: "tu_1".into(),
668                    name: "check_time".into(),
669                    input: json!({}),
670                },
671                ContentBlock::Text {
672                    text: "The time is noon.".into(),
673                },
674            ],
675            alt_index: None,
676            alt_count: None,
677            alternatives: vec![],
678            timestamp: "2026-01-01T00:00:00Z".into(),
679        };
680        let json = serde_json::to_value(&msg).unwrap();
681        // content_blocks should be present in serialized form
682        let blocks = json["content_blocks"].as_array().unwrap();
683        assert_eq!(blocks.len(), 3);
684        assert_eq!(blocks[0]["type"], "thinking");
685        assert_eq!(blocks[1]["type"], "tool_use");
686        assert_eq!(blocks[2]["type"], "text");
687        // Round-trip
688        let back: Message = serde_json::from_value(json).unwrap();
689        assert_eq!(back.content_blocks.len(), 3);
690        assert_eq!(back.content_blocks, msg.content_blocks);
691    }
692
693    #[test]
694    fn message_always_includes_content_blocks() {
695        let msg = Message {
696            msg_id: "m_old".into(),
697            role: Role::User,
698            content: "hello".into(),
699            images: vec![],
700            content_blocks: vec![],
701            alt_index: None,
702            alt_count: None,
703            alternatives: vec![],
704            timestamp: "2026-01-01T00:00:00Z".into(),
705        };
706        let json = serde_json::to_value(&msg).unwrap();
707        assert!(
708            json.get("content_blocks").is_some(),
709            "content_blocks should always be serialized"
710        );
711    }
712
713    #[test]
714    fn old_message_json_without_content_blocks_deserializes() {
715        // Simulate V1/old JSONL that has no content_blocks field
716        let json = json!({
717            "msg_id": "m_legacy",
718            "role": "assistant",
719            "content": "old message",
720            "timestamp": "2025-01-01T00:00:00Z"
721        });
722        let msg: Message = serde_json::from_value(json).unwrap();
723        assert!(msg.content_blocks.is_empty());
724        assert_eq!(msg.content, "old message");
725    }
726}