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