Skip to main content

agent_core/core/
rpc_dispatch.rs

1//! Pure helper functions for the `synaps rpc` command dispatcher.
2//!
3//! These are extracted from `cmd::rpc` so they can be unit-tested via
4//! `cargo test --lib` without hitting the binary-crate's TUI dependencies.
5//!
6//! See `docs/rpc-protocol.md` and `synaps-bridge.SPEC.md §4` for the wire
7//! protocol specification these functions implement.
8
9use crate::core::rpc_protocol::{AssistantEvent, RpcAttachment, RpcCommand, RpcEvent, TurnUsage};
10use crate::core::stream_types::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
11
12// ─── Frame parsing ────────────────────────────────────────────────────────────
13
14/// Maximum allowed inbound frame size in bytes (1 MiB).
15pub const MAX_FRAME_BYTES: usize = 1024 * 1024;
16
17/// Parse a raw UTF-8 line into an [`RpcCommand`], enforcing the 1 MiB frame limit.
18///
19/// # Returns
20/// - `Ok(RpcCommand)` on success.
21/// - `Err(RpcEvent::Error { id: None, … })` on oversize or malformed input —
22///   the caller should emit the error event and **continue** (do not exit).
23pub 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
36// ─── StreamEvent → RpcEvent mapping ──────────────────────────────────────────
37
38/// Map a single [`StreamEvent`] to an optional [`RpcEvent`].
39///
40/// Returns `None` for events that are intentionally dropped on the wire:
41/// - `LlmEvent::ToolResultDelta` — wire format has no streaming-result variant;
42///   the final `ToolResult` carries the complete text.
43/// - `AgentEvent::SteeringDelivered` — internal hook signal, not exposed.
44///
45/// `Session(*)` variants also return `None` — they carry session bookkeeping
46/// data (message history, usage counters, completion/error signals) that the
47/// streaming loop in `cmd::rpc` must handle directly with mutable access to
48/// [`RpcState`].
49pub 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        // tool_name is intentionally dropped — already sent in ToolcallStart
74        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        // Drop — wire format has no streaming-result variant; final ToolResult carries full text
91        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        // Drop — internal hook signal, not part of wire format
123        StreamEvent::Agent(AgentEvent::SteeringDelivered { .. }) => None,
124
125        // Session bookkeeping events are handled by the streaming loop in cmd::rpc
126        // with direct mutable access to RpcState; they are never forwarded as-is.
127        StreamEvent::Session(_) => None,
128    }
129}
130
131// ─── Usage accumulator ────────────────────────────────────────────────────────
132
133/// Option-accumulate for the cache-TTL split buckets: stay `None` until a
134/// split value arrives, then sum. `None` means "split never reported" —
135/// distinct from an explicit zero. Shared by every Usage accumulator that
136/// tracks the 5m/1h breakdown (RPC dispatch, subagent loops).
137pub 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
143/// Accumulate a [`SessionEvent::Usage`] payload into a [`TurnUsage`] counter.
144///
145/// Non-Usage session events are silently ignored so callers can pass any
146/// [`SessionEvent`] without pre-filtering.  The `model` field is set from the
147/// first Usage event seen and never overwritten.
148pub 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        // Option-accumulate: stay None until a split arrives, then sum.
164        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
172// ─── User-content builder ─────────────────────────────────────────────────────
173
174/// Build the user message string to push into `api_messages`.
175///
176/// When attachments are present (v0) a human-readable note listing the file
177/// paths is prepended.  File bytes are **not** read — Task 10 handles that.
178fn 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
191// ─── tools_list helper ───────────────────────────────────────────────────────
192
193/// Build the `tools_list` response body from a `ToolRegistry` schema snapshot.
194///
195/// The schema entries produced by [`ToolRegistry::tools_schema`] already have
196/// the shape `{name, description, input_schema}` that the bridge Phase 8
197/// `SynapsRpcSessionRouter.listTools()` expects. This function wraps them in
198/// the top-level `{ ok: true, tools: [...] }` envelope that the bridge
199/// validates (router.js line 112).
200pub 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// ─── Tests ────────────────────────────────────────────────────────────────────
208
209#[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    // ── parse_frame ──────────────────────────────────────────────────────────
219
220    #[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        // Unknown `type` tags should be a deserialisation error (serde enum).
286        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        // A well-formed frame at exactly the limit must not trigger the size error.
311        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        // Oversize relative to a custom limit.
320        let line = r#"{"type":"shutdown"}"#; // 19 bytes
321        let result = parse_frame(line, 5); // limit = 5
322        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    // ── map_stream_event ─────────────────────────────────────────────────────
330
331    #[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(), // must be dropped per spec
395            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                // tool_name intentionally absent from ToolcallInput
406            }
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        // All Session variants return None; the streaming loop handles them
524        // directly with mutable access to RpcState.
525        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    // ── accumulate_usage ─────────────────────────────────────────────────────
553
554    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        // Split: Option-accumulate — Some once any split arrives.
614        assert_eq!(acc.cache_creation_5m, Some(5));
615        assert_eq!(acc.cache_creation_1h, Some(7));
616        // Model must NOT be overwritten once set (first-wins semantics)
617        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    // ── build_user_content ───────────────────────────────────────────────────
674
675    #[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    // ── build_user_content: quoting edge cases ───────────────────────────────
730
731    #[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        // Must NOT appear as bare unquoted path
744        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    // ── build_tools_list_body ────────────────────────────────────────────────
800
801    #[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    /// The bridge checks `response.ok === true && Array.isArray(response.tools)`.
824    /// Verify the body round-trips through serde and satisfies both conditions.
825    #[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        // Simulate serialise → deserialise (what the parent process and bridge each do).
830        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    // ── handle_compact lock-release invariant ────────────────────────────────
840
841    /// Structural proof that `handle_compact` releases the state lock before
842    /// the long-running `compact_conversation` await.
843    ///
844    /// The fix in `cmd::rpc::handle_compact` snapshots `(msgs, runtime)` inside
845    /// a block that ends *before* the await, so the `MutexGuard` is dropped at
846    /// the closing `}`.  This test uses a `tokio::sync::Mutex` to demonstrate
847    /// the same pattern: a second task can acquire the lock while the "slow
848    /// operation" is running, proving contention is bounded to the snapshot
849    /// phase only.
850    #[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        // Simulate the fixed handle_compact pattern:
858        //   1. brief lock to snapshot data
859        //   2. long operation with NO lock
860        //   3. brief lock to write result
861        let shared2 = shared.clone();
862        let task = tokio::spawn(async move {
863            // Phase 1: snapshot under lock.
864            let snapshot = {
865                let mut g = shared2.lock().await;
866                *g += 1; // mark "lock acquired for snapshot"
867                *g // return snapshot value
868            };
869            // Lock is now RELEASED.
870
871            // Phase 2: slow operation — no lock held.
872            tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
873
874            // Phase 3: write result back under lock.
875            let mut g = shared2.lock().await;
876            *g = snapshot + 100;
877        });
878
879        // While the "slow" phase is running, this second task must be able to
880        // acquire the lock without blocking for the full 20 ms.
881        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}