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/// - `LlmEvent::ToolQueued` / `ToolStarted` — scheduler queue hints for the
44///   TUI; the rpc protocol has no capability handshake to opt into them.
45/// - `AgentEvent::SteeringDelivered` — internal hook signal, not exposed.
46///
47/// `Session(*)` variants also return `None` — they carry session bookkeeping
48/// data (message history, usage counters, completion/error signals) that the
49/// streaming loop in `cmd::rpc` must handle directly with mutable access to
50/// [`RpcState`].
51pub 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        // tool_name is intentionally dropped — already sent in ToolcallStart
82        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        // Drop — wire format has no streaming-result variant; final ToolResult carries full text
99        StreamEvent::Llm(LlmEvent::ToolResultDelta { .. }) => None,
100        // Drop — scheduler queue state is a TUI presentation hint. The rpc
101        // protocol is exact-match versioned with no capability handshake and
102        // `AssistantEvent` has no `#[serde(other)]` fallback, so a new
103        // variant would break existing parents; they keep today's stream.
104        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        // Drop — internal hook signal, not part of wire format
136        StreamEvent::Agent(AgentEvent::SteeringDelivered { .. }) => None,
137
138        // Session bookkeeping events are handled by the streaming loop in cmd::rpc
139        // with direct mutable access to RpcState; they are never forwarded as-is.
140        StreamEvent::Session(_) => None,
141    }
142}
143
144// ─── Usage accumulator ────────────────────────────────────────────────────────
145
146/// Option-accumulate for the cache-TTL split buckets: stay `None` until a
147/// split value arrives, then sum. `None` means "split never reported" —
148/// distinct from an explicit zero. Shared by every Usage accumulator that
149/// tracks the 5m/1h breakdown (RPC dispatch, subagent loops).
150pub 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
156/// Accumulate a [`SessionEvent::Usage`] payload into a [`TurnUsage`] counter.
157///
158/// Non-Usage session events are silently ignored so callers can pass any
159/// [`SessionEvent`] without pre-filtering.  The `model` field is set from the
160/// first Usage event seen and never overwritten.
161pub 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        // Option-accumulate: stay None until a split arrives, then sum.
177        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
185// ─── User-content builder ─────────────────────────────────────────────────────
186
187/// Build the user message string to push into `api_messages`.
188///
189/// Legacy display-only path hint formatter. This is NOT an upload API.
190/// The RPC ingress uses `agent_engine::attachments::build_user_content` to
191/// validate/read local files and embed actual blocks; retain this pure helper
192/// for compatibility with consumers that only need a textual reference.
193fn 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
206// ─── tools_list helper ───────────────────────────────────────────────────────
207
208/// Build the `tools_list` response body from a `ToolRegistry` schema snapshot.
209///
210/// The schema entries produced by [`ToolRegistry::tools_schema`] already have
211/// the shape `{name, description, input_schema}` that the bridge Phase 8
212/// `SynapsRpcSessionRouter.listTools()` expects. This function wraps them in
213/// the top-level `{ ok: true, tools: [...] }` envelope that the bridge
214/// validates (router.js line 112).
215pub 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// ─── Tests ────────────────────────────────────────────────────────────────────
223
224#[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    // ── parse_frame ──────────────────────────────────────────────────────────
234
235    #[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        // Unknown `type` tags should be a deserialisation error (serde enum).
301        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        // A well-formed frame at exactly the limit must not trigger the size error.
326        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        // Oversize relative to a custom limit.
335        let line = r#"{"type":"shutdown"}"#; // 19 bytes
336        let result = parse_frame(line, 5); // limit = 5
337        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    // ── map_stream_event ─────────────────────────────────────────────────────
345
346    #[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(), // must be dropped per spec
410            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                // tool_name intentionally absent from ToolcallInput
421            }
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        // All Session variants return None; the streaming loop handles them
539        // directly with mutable access to RpcState.
540        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    // ── accumulate_usage ─────────────────────────────────────────────────────
568
569    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        // Split: Option-accumulate — Some once any split arrives.
629        assert_eq!(acc.cache_creation_5m, Some(5));
630        assert_eq!(acc.cache_creation_1h, Some(7));
631        // Model must NOT be overwritten once set (first-wins semantics)
632        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    // ── build_user_content ───────────────────────────────────────────────────
689
690    #[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    // ── build_user_content: quoting edge cases ───────────────────────────────
745
746    #[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        // Must NOT appear as bare unquoted path
759        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    // ── build_tools_list_body ────────────────────────────────────────────────
815
816    #[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    /// The bridge checks `response.ok === true && Array.isArray(response.tools)`.
839    /// Verify the body round-trips through serde and satisfies both conditions.
840    #[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        // Simulate serialise → deserialise (what the parent process and bridge each do).
845        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    // ── handle_compact lock-release invariant ────────────────────────────────
855
856    /// Structural proof that `handle_compact` releases the state lock before
857    /// the long-running `compact_conversation` await.
858    ///
859    /// The fix in `cmd::rpc::handle_compact` snapshots `(msgs, runtime)` inside
860    /// a block that ends *before* the await, so the `MutexGuard` is dropped at
861    /// the closing `}`.  This test uses a `tokio::sync::Mutex` to demonstrate
862    /// the same pattern: a second task can acquire the lock while the "slow
863    /// operation" is running, proving contention is bounded to the snapshot
864    /// phase only.
865    #[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        // Simulate the fixed handle_compact pattern:
873        //   1. brief lock to snapshot data
874        //   2. long operation with NO lock
875        //   3. brief lock to write result
876        let shared2 = shared.clone();
877        let task = tokio::spawn(async move {
878            // Phase 1: snapshot under lock.
879            let snapshot = {
880                let mut g = shared2.lock().await;
881                *g += 1; // mark "lock acquired for snapshot"
882                *g // return snapshot value
883            };
884            // Lock is now RELEASED.
885
886            // Phase 2: slow operation — no lock held.
887            tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
888
889            // Phase 3: write result back under lock.
890            let mut g = shared2.lock().await;
891            *g = snapshot + 100;
892        });
893
894        // While the "slow" phase is running, this second task must be able to
895        // acquire the lock without blocking for the full 20 ms.
896        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}