Skip to main content

shore_protocol/
lib.rs

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