1use crate::core::rpc_protocol::{AssistantEvent, RpcAttachment, RpcCommand, RpcEvent, TurnUsage};
10use crate::core::stream_types::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
11
12pub const MAX_FRAME_BYTES: usize = 1024 * 1024;
16
17pub fn parse_frame(line: &str, max_bytes: usize) -> Result<RpcCommand, RpcEvent> {
24 if line.len() > max_bytes {
25 return Err(RpcEvent::Error {
26 id: None,
27 message: "frame exceeds 1 MiB limit".to_string(),
28 });
29 }
30 serde_json::from_str::<RpcCommand>(line).map_err(|e| RpcEvent::Error {
31 id: None,
32 message: e.to_string(),
33 })
34}
35
36pub fn map_stream_event(ev: &StreamEvent) -> Option<RpcEvent> {
50 match ev {
51 StreamEvent::Llm(LlmEvent::Thinking(s)) => Some(RpcEvent::MessageUpdate {
52 event: AssistantEvent::ThinkingDelta { delta: s.clone() },
53 }),
54 StreamEvent::Llm(LlmEvent::Text(s)) => Some(RpcEvent::MessageUpdate {
55 event: AssistantEvent::TextDelta { delta: s.clone() },
56 }),
57 StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => {
58 Some(RpcEvent::MessageUpdate {
59 event: AssistantEvent::ToolcallStart {
60 tool_id: tool_id.clone(),
61 tool_name: tool_name.clone(),
62 },
63 })
64 }
65 StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, delta }) => {
66 Some(RpcEvent::MessageUpdate {
67 event: AssistantEvent::ToolcallInputDelta {
68 tool_id: tool_id.clone(),
69 delta: delta.clone(),
70 },
71 })
72 }
73 StreamEvent::Llm(LlmEvent::ToolUse { tool_id, input, .. }) => {
75 Some(RpcEvent::MessageUpdate {
76 event: AssistantEvent::ToolcallInput {
77 tool_id: tool_id.clone(),
78 input: input.clone(),
79 },
80 })
81 }
82 StreamEvent::Llm(LlmEvent::ToolResult { tool_id, result }) => {
83 Some(RpcEvent::MessageUpdate {
84 event: AssistantEvent::ToolcallResult {
85 tool_id: tool_id.clone(),
86 result: result.clone(),
87 },
88 })
89 }
90 StreamEvent::Llm(LlmEvent::ToolResultDelta { .. }) => None,
92
93 StreamEvent::Agent(AgentEvent::SubagentStart {
94 subagent_id,
95 agent_name,
96 task_preview,
97 }) => Some(RpcEvent::SubagentStart {
98 subagent_id: *subagent_id,
99 agent_name: agent_name.clone(),
100 task_preview: task_preview.clone(),
101 }),
102 StreamEvent::Agent(AgentEvent::SubagentUpdate {
103 subagent_id,
104 agent_name,
105 status,
106 }) => Some(RpcEvent::SubagentUpdate {
107 subagent_id: *subagent_id,
108 agent_name: agent_name.clone(),
109 status: status.clone(),
110 }),
111 StreamEvent::Agent(AgentEvent::SubagentDone {
112 subagent_id,
113 agent_name,
114 result_preview,
115 duration_secs,
116 }) => Some(RpcEvent::SubagentDone {
117 subagent_id: *subagent_id,
118 agent_name: agent_name.clone(),
119 result_preview: result_preview.clone(),
120 duration_secs: *duration_secs,
121 }),
122 StreamEvent::Agent(AgentEvent::SteeringDelivered { .. }) => None,
124
125 StreamEvent::Session(_) => None,
128 }
129}
130
131pub fn merge_split(acc: &mut Option<u64>, v: Option<u64>) {
138 if let Some(v) = v {
139 *acc = Some(acc.unwrap_or(0) + v);
140 }
141}
142
143pub fn accumulate_usage(acc: &mut TurnUsage, event: &SessionEvent) {
149 if let SessionEvent::Usage {
150 input_tokens,
151 output_tokens,
152 cache_read_input_tokens,
153 cache_creation_input_tokens,
154 cache_creation_5m,
155 cache_creation_1h,
156 model,
157 } = event
158 {
159 acc.input_tokens += input_tokens;
160 acc.output_tokens += output_tokens;
161 acc.cache_read_input_tokens += cache_read_input_tokens;
162 acc.cache_creation_input_tokens += cache_creation_input_tokens;
163 merge_split(&mut acc.cache_creation_5m, *cache_creation_5m);
165 merge_split(&mut acc.cache_creation_1h, *cache_creation_1h);
166 if acc.model.is_none() {
167 acc.model = model.clone();
168 }
169 }
170}
171
172fn quote_path(p: &str) -> String {
179 let escaped = p.replace('\\', "\\\\").replace('"', "\\\"");
180 format!("\"{escaped}\"")
181}
182
183pub fn build_user_content(message: &str, attachments: &[RpcAttachment]) -> String {
184 if attachments.is_empty() {
185 return message.to_string();
186 }
187 let parts: Vec<String> = attachments.iter().map(|a| quote_path(&a.path)).collect();
188 format!("[user attached files: {}]\n{}", parts.join(", "), message)
189}
190
191pub fn build_tools_list_body(tools_schema: &[serde_json::Value]) -> serde_json::Value {
201 serde_json::json!({
202 "ok": true,
203 "tools": tools_schema,
204 })
205}
206
207#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::core::rpc_protocol::{
213 AssistantEvent, RpcAttachment, RpcCommand, RpcEvent, TurnUsage,
214 };
215 use crate::core::stream_types::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
216 use serde_json::json;
217
218 #[test]
221 fn parse_frame_valid_prompt() {
222 let line = r#"{"type":"prompt","id":"abc","message":"hello"}"#;
223 let result = parse_frame(line, MAX_FRAME_BYTES);
224 assert!(result.is_ok(), "should parse valid prompt frame");
225 match result.unwrap() {
226 RpcCommand::Prompt {
227 id,
228 message,
229 attachments,
230 } => {
231 assert_eq!(id, "abc");
232 assert_eq!(message, "hello");
233 assert!(attachments.is_empty());
234 }
235 other => panic!("unexpected variant: {:?}", other),
236 }
237 }
238
239 #[test]
240 fn parse_frame_valid_shutdown() {
241 let line = r#"{"type":"shutdown"}"#;
242 let result = parse_frame(line, MAX_FRAME_BYTES);
243 assert!(result.is_ok());
244 assert!(matches!(result.unwrap(), RpcCommand::Shutdown));
245 }
246
247 #[test]
248 fn parse_frame_valid_follow_up() {
249 let line = r#"{"type":"follow_up","id":"f1","message":"and then?"}"#;
250 let result = parse_frame(line, MAX_FRAME_BYTES);
251 match result.unwrap() {
252 RpcCommand::FollowUp { id, message } => {
253 assert_eq!(id, "f1");
254 assert_eq!(message, "and then?");
255 }
256 other => panic!("unexpected: {:?}", other),
257 }
258 }
259
260 #[test]
261 fn parse_frame_valid_abort() {
262 let line = r#"{"type":"abort","id":"x"}"#;
263 assert!(matches!(
264 parse_frame(line, MAX_FRAME_BYTES).unwrap(),
265 RpcCommand::Abort { .. }
266 ));
267 }
268
269 #[test]
270 fn parse_frame_malformed_json() {
271 let line = "not json at all";
272 let result = parse_frame(line, MAX_FRAME_BYTES);
273 assert!(result.is_err());
274 match result.unwrap_err() {
275 RpcEvent::Error { id, message } => {
276 assert!(id.is_none(), "malformed-JSON error must have id=None");
277 assert!(!message.is_empty(), "error message must be non-empty");
278 }
279 other => panic!("unexpected event: {:?}", other),
280 }
281 }
282
283 #[test]
284 fn parse_frame_valid_json_unknown_type() {
285 let line = r#"{"type":"does_not_exist","id":"1"}"#;
287 let result = parse_frame(line, MAX_FRAME_BYTES);
288 assert!(result.is_err(), "unknown type should fail to deserialise");
289 }
290
291 #[test]
292 fn parse_frame_oversize() {
293 let oversize = "x".repeat(MAX_FRAME_BYTES + 1);
294 let result = parse_frame(&oversize, MAX_FRAME_BYTES);
295 assert!(result.is_err());
296 match result.unwrap_err() {
297 RpcEvent::Error { id, message } => {
298 assert!(id.is_none());
299 assert!(
300 message.contains("1 MiB"),
301 "expected '1 MiB' in message, got: {message}"
302 );
303 }
304 other => panic!("unexpected event: {:?}", other),
305 }
306 }
307
308 #[test]
309 fn parse_frame_exactly_at_limit_valid_json() {
310 let line = r#"{"type":"get_state","id":"x"}"#;
312 assert!(line.len() <= MAX_FRAME_BYTES);
313 let result = parse_frame(line, MAX_FRAME_BYTES);
314 assert!(result.is_ok());
315 }
316
317 #[test]
318 fn parse_frame_custom_small_limit() {
319 let line = r#"{"type":"shutdown"}"#; let result = parse_frame(line, 5); assert!(result.is_err());
323 match result.unwrap_err() {
324 RpcEvent::Error { id, .. } => assert!(id.is_none()),
325 other => panic!("unexpected: {:?}", other),
326 }
327 }
328
329 #[test]
332 fn map_llm_thinking() {
333 let ev = StreamEvent::Llm(LlmEvent::Thinking("hmm".to_string()));
334 let rpc = map_stream_event(&ev).expect("Thinking must produce an event");
335 match rpc {
336 RpcEvent::MessageUpdate {
337 event: AssistantEvent::ThinkingDelta { delta },
338 } => assert_eq!(delta, "hmm"),
339 other => panic!("unexpected: {:?}", other),
340 }
341 }
342
343 #[test]
344 fn map_llm_text() {
345 let ev = StreamEvent::Llm(LlmEvent::Text("hi".to_string()));
346 let rpc = map_stream_event(&ev).expect("Text must produce an event");
347 match rpc {
348 RpcEvent::MessageUpdate {
349 event: AssistantEvent::TextDelta { delta },
350 } => assert_eq!(delta, "hi"),
351 other => panic!("unexpected: {:?}", other),
352 }
353 }
354
355 #[test]
356 fn map_llm_tool_use_start() {
357 let ev = StreamEvent::Llm(LlmEvent::ToolUseStart {
358 tool_name: "bash".to_string(),
359 tool_id: "tid1".to_string(),
360 });
361 let rpc = map_stream_event(&ev).expect("ToolUseStart must produce an event");
362 match rpc {
363 RpcEvent::MessageUpdate {
364 event: AssistantEvent::ToolcallStart { tool_id, tool_name },
365 } => {
366 assert_eq!(tool_id, "tid1");
367 assert_eq!(tool_name, "bash");
368 }
369 other => panic!("unexpected: {:?}", other),
370 }
371 }
372
373 #[test]
374 fn map_llm_tool_use_delta() {
375 let ev = StreamEvent::Llm(LlmEvent::ToolUseDelta {
376 tool_id: "tid1".to_string(),
377 delta: r#"{"cmd":"#.to_string(),
378 });
379 let rpc = map_stream_event(&ev).expect("ToolUseDelta must produce an event");
380 match rpc {
381 RpcEvent::MessageUpdate {
382 event: AssistantEvent::ToolcallInputDelta { tool_id, delta },
383 } => {
384 assert_eq!(tool_id, "tid1");
385 assert_eq!(delta, r#"{"cmd":"#);
386 }
387 other => panic!("unexpected: {:?}", other),
388 }
389 }
390
391 #[test]
392 fn map_llm_tool_use_final_drops_tool_name() {
393 let ev = StreamEvent::Llm(LlmEvent::ToolUse {
394 tool_name: "bash".to_string(), tool_id: "tid1".to_string(),
396 input: json!({"cmd": "ls"}),
397 });
398 let rpc = map_stream_event(&ev).expect("ToolUse must produce an event");
399 match rpc {
400 RpcEvent::MessageUpdate {
401 event: AssistantEvent::ToolcallInput { tool_id, input },
402 } => {
403 assert_eq!(tool_id, "tid1");
404 assert_eq!(input, json!({"cmd": "ls"}));
405 }
407 other => panic!("unexpected: {:?}", other),
408 }
409 }
410
411 #[test]
412 fn map_llm_tool_result() {
413 let ev = StreamEvent::Llm(LlmEvent::ToolResult {
414 tool_id: "tid1".to_string(),
415 result: "output here".to_string(),
416 });
417 let rpc = map_stream_event(&ev).expect("ToolResult must produce an event");
418 match rpc {
419 RpcEvent::MessageUpdate {
420 event: AssistantEvent::ToolcallResult { tool_id, result },
421 } => {
422 assert_eq!(tool_id, "tid1");
423 assert_eq!(result, "output here");
424 }
425 other => panic!("unexpected: {:?}", other),
426 }
427 }
428
429 #[test]
430 fn map_llm_tool_result_delta_is_dropped() {
431 let ev = StreamEvent::Llm(LlmEvent::ToolResultDelta {
432 tool_id: "tid1".to_string(),
433 delta: "partial".to_string(),
434 });
435 assert!(
436 map_stream_event(&ev).is_none(),
437 "ToolResultDelta must be dropped — wire format has no streaming-result variant"
438 );
439 }
440
441 #[test]
442 fn map_agent_subagent_start() {
443 let ev = StreamEvent::Agent(AgentEvent::SubagentStart {
444 subagent_id: 7,
445 agent_name: "worker".to_string(),
446 task_preview: "do thing".to_string(),
447 });
448 let rpc = map_stream_event(&ev).expect("SubagentStart must produce an event");
449 match rpc {
450 RpcEvent::SubagentStart {
451 subagent_id,
452 agent_name,
453 task_preview,
454 } => {
455 assert_eq!(subagent_id, 7);
456 assert_eq!(agent_name, "worker");
457 assert_eq!(task_preview, "do thing");
458 }
459 other => panic!("unexpected: {:?}", other),
460 }
461 }
462
463 #[test]
464 fn map_agent_subagent_update() {
465 let ev = StreamEvent::Agent(AgentEvent::SubagentUpdate {
466 subagent_id: 7,
467 agent_name: "worker".to_string(),
468 status: "running".to_string(),
469 });
470 let rpc = map_stream_event(&ev).expect("SubagentUpdate must produce an event");
471 match rpc {
472 RpcEvent::SubagentUpdate {
473 subagent_id,
474 agent_name,
475 status,
476 } => {
477 assert_eq!(subagent_id, 7);
478 assert_eq!(agent_name, "worker");
479 assert_eq!(status, "running");
480 }
481 other => panic!("unexpected: {:?}", other),
482 }
483 }
484
485 #[test]
486 fn map_agent_subagent_done() {
487 let ev = StreamEvent::Agent(AgentEvent::SubagentDone {
488 subagent_id: 7,
489 agent_name: "worker".to_string(),
490 result_preview: "done!".to_string(),
491 duration_secs: 1.5,
492 });
493 let rpc = map_stream_event(&ev).expect("SubagentDone must produce an event");
494 match rpc {
495 RpcEvent::SubagentDone {
496 subagent_id,
497 agent_name,
498 result_preview,
499 duration_secs,
500 } => {
501 assert_eq!(subagent_id, 7);
502 assert_eq!(agent_name, "worker");
503 assert_eq!(result_preview, "done!");
504 assert!((duration_secs - 1.5).abs() < f64::EPSILON);
505 }
506 other => panic!("unexpected: {:?}", other),
507 }
508 }
509
510 #[test]
511 fn map_agent_steering_delivered_is_dropped() {
512 let ev = StreamEvent::Agent(AgentEvent::SteeringDelivered {
513 message: "steer".to_string(),
514 });
515 assert!(
516 map_stream_event(&ev).is_none(),
517 "SteeringDelivered must be dropped — internal hook signal"
518 );
519 }
520
521 #[test]
522 fn map_session_events_all_return_none() {
523 let events: &[StreamEvent] = &[
526 StreamEvent::Session(SessionEvent::Done),
527 StreamEvent::Session(SessionEvent::Error(crate::TurnError::provider(
528 "oops",
529 "api_status",
530 "turn-test-0",
531 ))),
532 StreamEvent::Session(SessionEvent::MessageHistory(vec![])),
533 StreamEvent::Session(SessionEvent::Usage {
534 input_tokens: 1,
535 output_tokens: 2,
536 cache_read_input_tokens: 0,
537 cache_creation_input_tokens: 0,
538 cache_creation_5m: None,
539 cache_creation_1h: None,
540 model: None,
541 }),
542 ];
543 for ev in events {
544 assert!(
545 map_stream_event(ev).is_none(),
546 "Session event {:?} should return None",
547 ev
548 );
549 }
550 }
551
552 fn zero_usage() -> TurnUsage {
555 TurnUsage {
556 input_tokens: 0,
557 output_tokens: 0,
558 cache_read_input_tokens: 0,
559 cache_creation_input_tokens: 0,
560 cache_creation_5m: None,
561 cache_creation_1h: None,
562 model: None,
563 }
564 }
565
566 #[test]
567 fn accumulate_usage_basic() {
568 let mut acc = zero_usage();
569 let ev = SessionEvent::Usage {
570 input_tokens: 100,
571 output_tokens: 50,
572 cache_read_input_tokens: 10,
573 cache_creation_input_tokens: 5,
574 cache_creation_5m: Some(3),
575 cache_creation_1h: Some(2),
576 model: Some("claude-3-5".to_string()),
577 };
578 accumulate_usage(&mut acc, &ev);
579 assert_eq!(acc.input_tokens, 100);
580 assert_eq!(acc.output_tokens, 50);
581 assert_eq!(acc.cache_read_input_tokens, 10);
582 assert_eq!(acc.cache_creation_input_tokens, 5);
583 assert_eq!(acc.cache_creation_5m, Some(3));
584 assert_eq!(acc.cache_creation_1h, Some(2));
585 assert_eq!(acc.model.as_deref(), Some("claude-3-5"));
586 }
587
588 #[test]
589 fn accumulate_usage_additive_across_calls() {
590 let mut acc = TurnUsage {
591 input_tokens: 10,
592 output_tokens: 5,
593 cache_read_input_tokens: 0,
594 cache_creation_input_tokens: 0,
595 cache_creation_5m: Some(4),
596 cache_creation_1h: None,
597 model: Some("first-model".to_string()),
598 };
599 let ev = SessionEvent::Usage {
600 input_tokens: 20,
601 output_tokens: 8,
602 cache_read_input_tokens: 2,
603 cache_creation_input_tokens: 1,
604 cache_creation_5m: Some(1),
605 cache_creation_1h: Some(7),
606 model: Some("second-model".to_string()),
607 };
608 accumulate_usage(&mut acc, &ev);
609 assert_eq!(acc.input_tokens, 30);
610 assert_eq!(acc.output_tokens, 13);
611 assert_eq!(acc.cache_read_input_tokens, 2);
612 assert_eq!(acc.cache_creation_input_tokens, 1);
613 assert_eq!(acc.cache_creation_5m, Some(5));
615 assert_eq!(acc.cache_creation_1h, Some(7));
616 assert_eq!(acc.model.as_deref(), Some("first-model"));
618 }
619
620 #[test]
621 fn accumulate_usage_sets_model_when_none() {
622 let mut acc = zero_usage();
623 let ev = SessionEvent::Usage {
624 input_tokens: 1,
625 output_tokens: 1,
626 cache_read_input_tokens: 0,
627 cache_creation_input_tokens: 0,
628 cache_creation_5m: None,
629 cache_creation_1h: None,
630 model: Some("my-model".to_string()),
631 };
632 accumulate_usage(&mut acc, &ev);
633 assert_eq!(acc.model.as_deref(), Some("my-model"));
634 }
635
636 #[test]
637 fn accumulate_usage_ignores_done() {
638 let mut acc = zero_usage();
639 acc.input_tokens = 5;
640 accumulate_usage(&mut acc, &SessionEvent::Done);
641 assert_eq!(acc.input_tokens, 5, "Done must not mutate the accumulator");
642 }
643
644 #[test]
645 fn accumulate_usage_ignores_error() {
646 let mut acc = zero_usage();
647 acc.output_tokens = 3;
648 accumulate_usage(
649 &mut acc,
650 &SessionEvent::Error(crate::TurnError::provider(
651 "boom",
652 "api_status",
653 "turn-test-1",
654 )),
655 );
656 assert_eq!(
657 acc.output_tokens, 3,
658 "Error must not mutate the accumulator"
659 );
660 }
661
662 #[test]
663 fn accumulate_usage_ignores_message_history() {
664 let mut acc = zero_usage();
665 acc.input_tokens = 7;
666 accumulate_usage(&mut acc, &SessionEvent::MessageHistory(vec![]));
667 assert_eq!(
668 acc.input_tokens, 7,
669 "MessageHistory must not mutate the accumulator"
670 );
671 }
672
673 #[test]
676 fn build_user_content_no_attachments() {
677 assert_eq!(build_user_content("hello", &[]), "hello");
678 }
679
680 #[test]
681 fn build_user_content_single_attachment() {
682 let attachments = vec![RpcAttachment {
683 path: "/tmp/a.txt".to_string(),
684 name: None,
685 mime: None,
686 }];
687 let msg = build_user_content("check this", &attachments);
688 assert!(msg.starts_with("[user attached files: \"/tmp/a.txt\"]"));
689 assert!(msg.contains("check this"));
690 }
691
692 #[test]
693 fn build_user_content_multiple_attachments() {
694 let attachments = vec![
695 RpcAttachment {
696 path: "/tmp/a.txt".to_string(),
697 name: None,
698 mime: None,
699 },
700 RpcAttachment {
701 path: "/tmp/b.pdf".to_string(),
702 name: None,
703 mime: None,
704 },
705 ];
706 let msg = build_user_content("check these", &attachments);
707 assert!(
708 msg.contains("[user attached files: \"/tmp/a.txt\", \"/tmp/b.pdf\"]"),
709 "paths must be quoted and comma-separated: {msg}"
710 );
711 assert!(msg.contains("check these"));
712 }
713
714 #[test]
715 fn build_user_content_preserves_original_message() {
716 let attachments = vec![RpcAttachment {
717 path: "/tmp/x".to_string(),
718 name: Some("x".to_string()),
719 mime: Some("text/plain".to_string()),
720 }];
721 let original = "multi\nline\nmessage";
722 let msg = build_user_content(original, &attachments);
723 assert!(
724 msg.ends_with(original),
725 "original message must appear verbatim at the end"
726 );
727 }
728
729 #[test]
732 fn build_user_content_path_with_comma_is_quoted() {
733 let attachments = vec![RpcAttachment {
734 path: "/tmp/a,b.pdf".to_string(),
735 name: None,
736 mime: None,
737 }];
738 let msg = build_user_content("look", &attachments);
739 assert!(
740 msg.contains("\"/tmp/a,b.pdf\""),
741 "comma path must be wrapped in quotes: {msg}"
742 );
743 assert!(
745 !msg.contains("[user attached files: /tmp/a,b.pdf]"),
746 "bare unquoted comma path must not appear: {msg}"
747 );
748 }
749
750 #[test]
751 fn build_user_content_multiple_paths_each_quoted() {
752 let attachments = vec![
753 RpcAttachment {
754 path: "/p1".to_string(),
755 name: None,
756 mime: None,
757 },
758 RpcAttachment {
759 path: "/p2".to_string(),
760 name: None,
761 mime: None,
762 },
763 ];
764 let msg = build_user_content("x", &attachments);
765 assert!(
766 msg.contains("\"/p1\", \"/p2\""),
767 "each path must be individually quoted: {msg}"
768 );
769 }
770
771 #[test]
772 fn build_user_content_path_with_embedded_quote_is_escaped() {
773 let attachments = vec![RpcAttachment {
774 path: "/tmp/he\"llo".to_string(),
775 name: None,
776 mime: None,
777 }];
778 let msg = build_user_content("x", &attachments);
779 assert!(
780 msg.contains("\"/tmp/he\\\"llo\""),
781 "embedded double-quote must be backslash-escaped: {msg}"
782 );
783 }
784
785 #[test]
786 fn build_user_content_path_with_backslash_is_escaped() {
787 let attachments = vec![RpcAttachment {
788 path: "/tmp/a\\b".to_string(),
789 name: None,
790 mime: None,
791 }];
792 let msg = build_user_content("x", &attachments);
793 assert!(
794 msg.contains("\"/tmp/a\\\\b\""),
795 "backslash in path must be doubled: {msg}"
796 );
797 }
798
799 #[test]
802 fn build_tools_list_body_empty() {
803 let body = super::build_tools_list_body(&[]);
804 assert_eq!(body["ok"], true);
805 assert!(body["tools"].is_array());
806 assert_eq!(body["tools"].as_array().unwrap().len(), 0);
807 }
808
809 #[test]
810 fn build_tools_list_body_with_entries() {
811 let schema = vec![
812 json!({"name": "bash", "description": "Run bash", "input_schema": {"type": "object"}}),
813 json!({"name": "read", "description": "Read file", "input_schema": {"type": "object"}}),
814 ];
815 let body = super::build_tools_list_body(&schema);
816 assert_eq!(body["ok"], true);
817 let tools = body["tools"].as_array().unwrap();
818 assert_eq!(tools.len(), 2);
819 assert_eq!(tools[0]["name"], "bash");
820 assert_eq!(tools[1]["name"], "read");
821 }
822
823 #[test]
826 fn build_tools_list_body_roundtrip_satisfies_bridge_contract() {
827 let schema = vec![json!({"name": "bash", "description": "desc", "input_schema": {}})];
828 let body = super::build_tools_list_body(&schema);
829 let serialised = serde_json::to_string(&body).unwrap();
831 let parsed: serde_json::Value = serde_json::from_str(&serialised).unwrap();
832 assert_eq!(parsed["ok"], true, "bridge check: ok===true");
833 assert!(
834 parsed["tools"].is_array(),
835 "bridge check: Array.isArray(tools)"
836 );
837 }
838
839 #[tokio::test]
851 async fn handle_compact_releases_lock_before_slow_await() {
852 use std::sync::Arc;
853 use tokio::sync::Mutex;
854
855 let shared: Arc<Mutex<u32>> = Arc::new(Mutex::new(0));
856
857 let shared2 = shared.clone();
862 let task = tokio::spawn(async move {
863 let snapshot = {
865 let mut g = shared2.lock().await;
866 *g += 1; *g };
869 tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
873
874 let mut g = shared2.lock().await;
876 *g = snapshot + 100;
877 });
878
879 tokio::time::sleep(tokio::time::Duration::from_millis(5)).await;
882 let acquired =
883 tokio::time::timeout(tokio::time::Duration::from_millis(5), shared.lock()).await;
884 assert!(
885 acquired.is_ok(),
886 "second task must acquire the lock during the slow phase — \
887 handle_compact must NOT hold the lock across compact_conversation"
888 );
889 drop(acquired);
890
891 task.await.unwrap();
892 assert_eq!(*shared.lock().await, 101);
893 }
894}