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