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