1pub mod client_msg;
2pub mod error;
3pub mod merge;
4pub mod server_msg;
5pub mod tool_display;
6pub mod types;
7
8pub const SWP_V1: u32 = 1;
10
11pub 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 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 #[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 #[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 #[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 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 #[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 #[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 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 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 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 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 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 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}