1#![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
46pub const SWP_V1: u32 = 1;
48
49pub 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 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 #[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 #[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 #[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 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 #[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 #[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 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 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 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 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 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 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}