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::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        // tool_name is intentionally dropped — already sent in ToolcallStart
80        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        // Drop — wire format has no streaming-result variant; final ToolResult carries full text
97        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        // Drop — internal hook signal, not part of wire format
129        StreamEvent::Agent(AgentEvent::SteeringDelivered { .. }) => None,
130
131        // Session bookkeeping events are handled by the streaming loop in cmd::rpc
132        // with direct mutable access to RpcState; they are never forwarded as-is.
133        StreamEvent::Session(_) => None,
134    }
135}
136
137// ─── Usage accumulator ────────────────────────────────────────────────────────
138
139/// Option-accumulate for the cache-TTL split buckets: stay `None` until a
140/// split value arrives, then sum. `None` means "split never reported" —
141/// distinct from an explicit zero. Shared by every Usage accumulator that
142/// tracks the 5m/1h breakdown (RPC dispatch, subagent loops).
143pub 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
149/// Accumulate a [`SessionEvent::Usage`] payload into a [`TurnUsage`] counter.
150///
151/// Non-Usage session events are silently ignored so callers can pass any
152/// [`SessionEvent`] without pre-filtering.  The `model` field is set from the
153/// first Usage event seen and never overwritten.
154pub 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        // Option-accumulate: stay None until a split arrives, then sum.
170        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
178// ─── User-content builder ─────────────────────────────────────────────────────
179
180/// Build the user message string to push into `api_messages`.
181///
182/// Legacy display-only path hint formatter. This is NOT an upload API.
183/// The RPC ingress uses `agent_engine::attachments::build_user_content` to
184/// validate/read local files and embed actual blocks; retain this pure helper
185/// for compatibility with consumers that only need a textual reference.
186fn 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
199// ─── tools_list helper ───────────────────────────────────────────────────────
200
201/// Build the `tools_list` response body from a `ToolRegistry` schema snapshot.
202///
203/// The schema entries produced by [`ToolRegistry::tools_schema`] already have
204/// the shape `{name, description, input_schema}` that the bridge Phase 8
205/// `SynapsRpcSessionRouter.listTools()` expects. This function wraps them in
206/// the top-level `{ ok: true, tools: [...] }` envelope that the bridge
207/// validates (router.js line 112).
208pub 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// ─── Tests ────────────────────────────────────────────────────────────────────
216
217#[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    // ── parse_frame ──────────────────────────────────────────────────────────
227
228    #[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        // Unknown `type` tags should be a deserialisation error (serde enum).
294        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        // A well-formed frame at exactly the limit must not trigger the size error.
319        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        // Oversize relative to a custom limit.
328        let line = r#"{"type":"shutdown"}"#; // 19 bytes
329        let result = parse_frame(line, 5); // limit = 5
330        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    // ── map_stream_event ─────────────────────────────────────────────────────
338
339    #[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(), // must be dropped per spec
403            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                // tool_name intentionally absent from ToolcallInput
414            }
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        // All Session variants return None; the streaming loop handles them
532        // directly with mutable access to RpcState.
533        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    // ── accumulate_usage ─────────────────────────────────────────────────────
561
562    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        // Split: Option-accumulate — Some once any split arrives.
622        assert_eq!(acc.cache_creation_5m, Some(5));
623        assert_eq!(acc.cache_creation_1h, Some(7));
624        // Model must NOT be overwritten once set (first-wins semantics)
625        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    // ── build_user_content ───────────────────────────────────────────────────
682
683    #[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    // ── build_user_content: quoting edge cases ───────────────────────────────
738
739    #[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        // Must NOT appear as bare unquoted path
752        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    // ── build_tools_list_body ────────────────────────────────────────────────
808
809    #[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    /// The bridge checks `response.ok === true && Array.isArray(response.tools)`.
832    /// Verify the body round-trips through serde and satisfies both conditions.
833    #[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        // Simulate serialise → deserialise (what the parent process and bridge each do).
838        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    // ── handle_compact lock-release invariant ────────────────────────────────
848
849    /// Structural proof that `handle_compact` releases the state lock before
850    /// the long-running `compact_conversation` await.
851    ///
852    /// The fix in `cmd::rpc::handle_compact` snapshots `(msgs, runtime)` inside
853    /// a block that ends *before* the await, so the `MutexGuard` is dropped at
854    /// the closing `}`.  This test uses a `tokio::sync::Mutex` to demonstrate
855    /// the same pattern: a second task can acquire the lock while the "slow
856    /// operation" is running, proving contention is bounded to the snapshot
857    /// phase only.
858    #[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        // Simulate the fixed handle_compact pattern:
866        //   1. brief lock to snapshot data
867        //   2. long operation with NO lock
868        //   3. brief lock to write result
869        let shared2 = shared.clone();
870        let task = tokio::spawn(async move {
871            // Phase 1: snapshot under lock.
872            let snapshot = {
873                let mut g = shared2.lock().await;
874                *g += 1; // mark "lock acquired for snapshot"
875                *g // return snapshot value
876            };
877            // Lock is now RELEASED.
878
879            // Phase 2: slow operation — no lock held.
880            tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
881
882            // Phase 3: write result back under lock.
883            let mut g = shared2.lock().await;
884            *g = snapshot + 100;
885        });
886
887        // While the "slow" phase is running, this second task must be able to
888        // acquire the lock without blocking for the full 20 ms.
889        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}