1use std::collections::BTreeMap;
59use std::sync::Arc;
60use std::time::Instant;
61
62use serde_json::{json, Value};
63
64use super::{AssistantEvent, AssistantOutcome, AssistantToolReceipt, AuthRequiredReason};
65
66pub const SCHEMA: &str = "car.do/1";
68
69const SUMMARY_CAP: usize = 4096;
75
76const BRIEF_CAP: usize = 160;
78
79const RECEIPT_SAMPLE: usize = 8;
83
84fn cap_text(s: &str, cap: usize) -> String {
86 if s.len() <= cap {
87 return s.to_string();
88 }
89 let mut end = cap;
90 while !s.is_char_boundary(end) {
91 end -= 1;
92 }
93 let elided = s.len() - end;
94 format!(
95 "{}\n…[truncated: {} of {} bytes shown; {} elided]…",
96 &s[..end],
97 end,
98 s.len(),
99 elided
100 )
101}
102
103fn brief(params: &Value) -> String {
110 const IDENTIFYING: &[&str] = &[
114 "command",
115 "path",
116 "url",
117 "query",
118 "goal",
119 "expression",
120 "subject",
121 "name",
122 "content",
123 ];
124 let raw = IDENTIFYING
125 .iter()
126 .find_map(|k| params.get(*k).and_then(Value::as_str))
127 .unwrap_or_default()
128 .replace('\n', " ");
129 cap_text(&raw, BRIEF_CAP)
130}
131
132#[derive(Clone)]
137pub struct SandboxPosture {
138 pub sandboxed: bool,
139 pub image: Option<String>,
140 pub tier: String,
141 pub root: String,
142 pub mount: Option<String>,
146 pub fallback_notice: Option<String>,
147}
148
149impl SandboxPosture {
150 pub fn to_json(&self) -> Value {
155 json!({
156 "mode": if self.sandboxed { "docker" } else { "local" },
157 "image": self.image,
158 "network": if self.sandboxed { "none" } else { "host" },
159 "tier": self.tier,
160 "root": self.root,
161 "mount": self.mount,
165 "fallback_notice": self.fallback_notice,
170 })
171 }
172}
173
174pub struct GoalReport {
176 pub check: String,
177 pub passed: bool,
178 pub grounded: bool,
182 pub iterations: u32,
183 pub halt: Option<String>,
184}
185
186impl GoalReport {
187 fn to_json(&self) -> Value {
188 json!({
189 "check": self.check,
190 "passed": self.passed,
191 "grounded": self.grounded,
192 "iterations": self.iterations,
193 "halt": self.halt,
194 })
195 }
196}
197
198pub trait EventSink: Send + Sync {
208 fn emit(&self, event: Value);
210}
211
212pub struct JsonEmitter {
214 started: Instant,
215 posture: SandboxPosture,
216 sink: Arc<dyn EventSink>,
217 delegations: std::sync::atomic::AtomicU32,
221}
222
223impl JsonEmitter {
224 pub fn new(posture: SandboxPosture, sink: Arc<dyn EventSink>) -> Self {
225 Self {
226 started: Instant::now(),
227 posture,
228 sink,
229 delegations: std::sync::atomic::AtomicU32::new(0),
230 }
231 }
232
233 fn event(&self, ty: &str, phase: &str, message: impl Into<String>, data: Value) {
235 self.sink.emit(json!({
236 "type": ty,
237 "phase": phase,
238 "message": message.into(),
239 "data": data,
240 }));
241 }
242
243 pub fn started(&self, goal: &str, model: &str) {
244 self.event(
245 "started",
246 "run",
247 "run started",
248 json!({
249 "goal": cap_text(goal, BRIEF_CAP),
250 "model": model,
251 "sandbox": self.posture.to_json(),
252 }),
253 );
254 }
255
256 pub fn on_assistant_event(&self, ev: &AssistantEvent) {
263 match ev {
264 AssistantEvent::InferenceStarted {
265 model,
266 attempt,
267 turn,
268 } => self.event(
269 "inference_started",
270 "inference",
271 format!("{model} inference attempt {attempt} started"),
272 json!({ "model": model, "attempt": attempt, "turn": turn }),
273 ),
274 AssistantEvent::InferenceRetry {
275 model,
276 attempt,
277 reason,
278 backoff_ms,
279 } => self.event(
280 "inference_retry",
281 "inference",
282 format!("{model} inference retry {attempt} after {reason}"),
283 json!({
284 "model": model,
285 "attempt": attempt,
286 "reason": reason,
287 "backoff_ms": backoff_ms,
288 }),
289 ),
290 AssistantEvent::ModelServed {
291 model_id,
292 local_last_resort,
293 } => self.event(
294 "model_served",
295 "inference",
296 if *local_last_resort {
297 format!("{model_id} served via on-device last-resort fallback")
298 } else {
299 format!("{model_id} served")
300 },
301 json!({
302 "model_id": model_id,
303 "local_last_resort": local_last_resort,
304 }),
305 ),
306 AssistantEvent::Text(t) if !t.trim().is_empty() => {
307 self.event("text", "reasoning", cap_text(t, BRIEF_CAP * 4), json!({}))
308 }
309 AssistantEvent::Text(_) => {}
310 AssistantEvent::ToolCall {
311 call_id,
312 sequence,
313 name,
314 params,
315 } => {
316 if name == super::agent_loop::DELEGATE_TOOL {
317 self.delegations
318 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
319 }
320 self.event(
321 "tool_called",
322 "acting",
323 format!("{name}({})", brief(params)),
324 json!({
325 "call_id": call_id,
326 "sequence": sequence,
327 "tool": name,
328 "brief": brief(params),
329 }),
330 )
331 }
332 AssistantEvent::ToolResult {
333 call_id,
334 sequence,
335 name,
336 ok,
337 ..
338 } => self.event(
339 if *ok { "tool_result" } else { "tool_failed" },
340 "acting",
341 format!("{name} {}", if *ok { "ok" } else { "failed" }),
342 json!({
343 "call_id": call_id,
344 "sequence": sequence,
345 "tool": name,
346 "ok": ok,
347 }),
348 ),
349 AssistantEvent::GoalEvaluated {
350 iteration,
351 met,
352 grounded,
353 reason,
354 } => self.event(
355 "goal_evaluated",
356 "verifying",
357 cap_text(reason, BRIEF_CAP * 2),
358 json!({
359 "iteration": iteration,
360 "met": met,
361 "grounded": grounded,
362 }),
363 ),
364 AssistantEvent::Done { .. }
369 | AssistantEvent::Error(_)
370 | AssistantEvent::AuthRequired { .. } => {}
371 }
372 }
373
374 fn receipts_json(receipts: &[AssistantToolReceipt]) -> Value {
376 let mut by_tool: BTreeMap<&str, u64> = BTreeMap::new();
377 let mut failed = 0u64;
378 for r in receipts {
379 *by_tool.entry(r.tool.as_str()).or_default() += 1;
380 if !r.ok {
381 failed += 1;
382 }
383 }
384 let sample: Vec<Value> = receipts
387 .iter()
388 .filter(|r| !r.ok)
389 .chain(receipts.iter().filter(|r| r.ok))
390 .take(RECEIPT_SAMPLE)
391 .map(|r| match &r.via {
392 Some(via) => {
395 json!({ "tool": r.tool, "ok": r.ok, "brief": brief(&r.params), "via": via })
396 }
397 None => json!({ "tool": r.tool, "ok": r.ok, "brief": brief(&r.params) }),
398 })
399 .collect();
400 let omitted = receipts.len().saturating_sub(sample.len());
401 json!({
402 "total": receipts.len(),
403 "failed": failed,
404 "by_tool": by_tool,
405 "sample": sample,
406 "sample_omitted": omitted,
409 })
410 }
411
412 pub fn finish(&self, outcome: &AssistantOutcome, goal: Option<&GoalReport>) -> Value {
422 if outcome.status == "error" || outcome.auth_required.is_some() {
423 return self.fail_run(outcome);
424 }
425 let ungrounded = super::ungrounded_summary_claims(&outcome.summary, &outcome.tool_receipts);
426 let elapsed = self.started.elapsed().as_secs_f64();
427
428 self.event(
429 "completed",
430 "run",
431 "run finished",
432 json!({
433 "status": outcome.status,
434 "turns": outcome.turns,
435 "models_served": outcome.models_served,
436 "elapsed_seconds": elapsed,
437 }),
438 );
439
440 let mut doc = json!({
441 "schema": SCHEMA,
442 "status": outcome.status,
443 "summary": cap_text(&outcome.summary, SUMMARY_CAP),
444 "turns": outcome.turns,
448 "delegations": self.delegations.load(std::sync::atomic::Ordering::Relaxed),
449 "model_used": outcome.model_used,
450 "models_served": outcome.models_served,
451 "receipts": Self::receipts_json(&outcome.tool_receipts),
452 "ungrounded_claims": ungrounded,
456 "sandbox": self.posture.to_json(),
457 "elapsed_seconds": elapsed,
458 });
459 if let Some(g) = goal {
460 doc["goal"] = g.to_json();
461 }
462 doc
463 }
464
465 fn fail_run(&self, outcome: &AssistantOutcome) -> Value {
476 let elapsed = self.started.elapsed().as_secs_f64();
477 let (status, error, suggestions) = match outcome.auth_required {
489 Some(reason) => (
490 "auth_required",
491 "AuthRequired",
492 vec![
493 match reason {
494 AuthRequiredReason::SignedOut => {
495 "Sign in to your Parslee account, then re-run."
496 }
497 AuthRequiredReason::Expired => {
498 "Your Parslee sign-in has expired. Sign in again, then re-run."
499 }
500 AuthRequiredReason::NoWorkspace => {
501 "Finish setting up your Parslee account at parslee.ai, then re-run."
502 }
503 },
504 "Or name a different model for this run with --model / CAR_DO_MODEL.",
505 ],
506 ),
507 None => (
508 "error",
509 "AssistantLoopFailed",
510 vec![
511 "Re-run the goal; the run failed mid-loop rather than completing with an \
512 answer.",
513 "Check `receipts` for what had already executed before the failure.",
514 ],
515 ),
516 };
517 let mut event_data = json!({
518 "error": error,
519 "turns": outcome.turns,
520 "turns_completed": outcome.turns_completed,
521 "models_served": outcome.models_served,
522 });
523 if let Some(cause) = outcome.failure_cause {
524 event_data["failure"] = json!(cause);
525 }
526 self.event(
527 "failed",
528 "run",
529 cap_text(&outcome.summary, BRIEF_CAP * 2),
530 event_data,
531 );
532 let mut doc = json!({
533 "schema": SCHEMA,
534 "status": status,
535 "error": error,
536 "message": cap_text(&outcome.summary, SUMMARY_CAP),
537 "turns": outcome.turns,
538 "turns_completed": outcome.turns_completed,
539 "model_used": outcome.model_used,
540 "models_served": outcome.models_served,
541 "receipts": Self::receipts_json(&outcome.tool_receipts),
542 "sandbox": self.posture.to_json(),
543 "elapsed_seconds": elapsed,
544 "suggestions": suggestions,
545 });
546 if let Some(reason) = outcome.auth_required {
547 doc["reason"] = json!(reason.as_str());
548 }
549 if let Some(cause) = outcome.failure_cause {
550 doc["failure"] = json!(cause);
551 }
552 doc
553 }
554}
555
556pub fn startup_error_doc(error: &str, message: &str, suggestions: &[&str]) -> Value {
566 json!({
567 "schema": SCHEMA,
568 "status": "error",
569 "error": error,
570 "message": message,
571 "suggestions": suggestions,
572 })
573}
574
575#[cfg(test)]
576mod tests {
577 use super::super::AssistantModelAttribution;
578 use super::*;
579
580 fn posture() -> SandboxPosture {
581 SandboxPosture {
582 sandboxed: true,
583 image: Some("python:3.11".into()),
584 tier: "SandboxEdit".into(),
585 root: "/work".into(),
586 mount: None,
587 fallback_notice: None,
588 }
589 }
590
591 #[test]
592 fn cap_text_states_what_it_dropped() {
593 let s = "x".repeat(100);
594 let out = cap_text(&s, 10);
595 assert!(out.starts_with(&"x".repeat(10)));
596 assert!(out.contains("90 elided"), "{out}");
597 }
598
599 #[test]
600 fn cap_text_leaves_short_input_untouched() {
601 assert_eq!(cap_text("short", 100), "short");
602 }
603
604 #[test]
605 fn cap_text_respects_char_boundaries() {
606 let s = "é".repeat(50);
608 let out = cap_text(&s, 11);
609 assert!(out.contains("elided"), "{out}");
610 }
611
612 #[test]
613 fn brief_prefers_identifying_keys_and_flattens_newlines() {
614 let p = json!({ "command": "cargo test\n--quiet", "body": "…huge…" });
615 assert_eq!(brief(&p), "cargo test --quiet");
616 }
617
618 #[test]
619 fn brief_is_empty_when_no_identifying_key_is_present() {
620 assert_eq!(brief(&json!({ "body": "opaque" })), "");
621 }
622
623 #[test]
624 fn brief_covers_the_tools_that_do_not_take_a_command_or_path() {
625 assert_eq!(brief(&json!({ "expression": "17 * 23" })), "17 * 23");
628 assert_eq!(
629 brief(&json!({ "query": "rust lifetimes" })),
630 "rust lifetimes"
631 );
632 assert_eq!(
633 brief(&json!({ "subject": "deploy cadence" })),
634 "deploy cadence"
635 );
636 }
637
638 #[test]
639 fn receipts_roll_up_counts_and_put_failures_in_the_sample_first() {
640 let mut receipts: Vec<AssistantToolReceipt> = (0..20)
641 .map(|i| AssistantToolReceipt {
642 tool: "shell".into(),
643 call_id: None,
644 sequence: None,
645 ok: true,
646 params: json!({ "command": format!("ok-{i}") }),
647 result: None,
648 via: None,
649 })
650 .collect();
651 receipts.push(AssistantToolReceipt {
652 tool: "write_file".into(),
653 call_id: None,
654 sequence: None,
655 ok: false,
656 params: json!({ "path": "/denied" }),
657 result: None,
658 via: None,
659 });
660
661 let v = JsonEmitter::receipts_json(&receipts);
662 assert_eq!(v["total"], 21);
663 assert_eq!(v["failed"], 1);
664 assert_eq!(v["by_tool"]["shell"], 20);
665 assert_eq!(v["by_tool"]["write_file"], 1);
666 assert_eq!(v["sample"][0]["tool"], "write_file");
668 assert_eq!(v["sample"].as_array().unwrap().len(), RECEIPT_SAMPLE);
669 assert_eq!(v["sample_omitted"], 21 - RECEIPT_SAMPLE);
671 }
672
673 #[test]
674 fn sandbox_posture_distinguishes_a_fallback_from_a_choice() {
675 let chosen = posture().to_json();
676 assert_eq!(chosen["mode"], "docker");
677 assert_eq!(chosen["network"], "none");
678 assert!(chosen["fallback_notice"].is_null());
679
680 let fell_back = SandboxPosture {
681 sandboxed: false,
682 image: None,
683 fallback_notice: Some("Docker not running".into()),
684 ..posture()
685 }
686 .to_json();
687 assert_eq!(fell_back["mode"], "local");
688 assert_eq!(fell_back["network"], "host");
689 assert_eq!(fell_back["fallback_notice"], "Docker not running");
690 }
691
692 #[test]
693 fn an_errored_run_is_not_reported_as_a_result() {
694 let outcome = AssistantOutcome {
698 status: "error",
699 summary: "connection reset by peer".into(),
700 turns: 9,
701 turns_completed: 8,
702 tools_called: vec![],
703 prior_receipts: 0,
704 tool_receipts: vec![AssistantToolReceipt {
705 tool: "shell".into(),
706 call_id: None,
707 sequence: None,
708 ok: true,
709 params: json!({ "command": "ls" }),
710 result: None,
711 via: None,
712 }],
713 models_served: vec![AssistantModelAttribution {
714 model_id: "claude-opus-5".into(),
715 local_last_resort: false,
716 }],
717 model_used: "claude-opus-5".into(),
718 auth_required: None,
719 failure_cause: Some(
720 super::super::agent_loop::AssistantFailureCause::TransientInference {
721 status: None,
722 },
723 ),
724 };
725 let sink = Arc::new(Captured::default());
726 let doc = JsonEmitter::new(posture(), sink.clone()).finish(&outcome, None);
727 assert_eq!(doc["status"], "error");
728 assert!(doc.get("summary").is_none(), "summary leaked: {doc}");
729 assert_eq!(doc["message"], "connection reset by peer");
730 assert_eq!(doc["turns"], 9);
731 assert_eq!(doc["turns_completed"], 8);
732 assert_eq!(doc["failure"]["cause"], "transient_inference");
733 assert!(doc["failure"]["status"].is_null());
734 assert_eq!(doc["receipts"]["total"], 1);
736 assert_eq!(doc["models_served"][0]["model_id"], "claude-opus-5");
737 let events = sink.0.lock().unwrap();
738 assert_eq!(events.len(), 1);
739 assert_eq!(events[0]["type"], "failed");
740 assert_eq!(events[0]["data"]["turns_completed"], 8);
741 assert_eq!(events[0]["data"]["failure"], doc["failure"]);
742 assert_eq!(events[0]["data"]["models_served"], doc["models_served"]);
743 }
744
745 #[test]
746 fn an_account_refusal_is_a_failed_document_with_its_own_status() {
747 let outcome = AssistantOutcome {
748 status: "auth_required",
749 summary: super::super::agent_loop::AUTH_REQUIRED_SIGNED_OUT_MESSAGE.into(),
750 turns: 1,
751 turns_completed: 0,
752 tools_called: vec![],
753 tool_receipts: vec![],
754 prior_receipts: 0,
755 models_served: vec![],
756 model_used: String::new(),
757 auth_required: Some(AuthRequiredReason::SignedOut),
758 failure_cause: None,
759 };
760 let sink = Arc::new(Captured::default());
761 let doc = JsonEmitter::new(posture(), sink.clone()).finish(&outcome, None);
762
763 assert_eq!(doc["schema"], "car.do/1");
766 assert_eq!(doc["status"], "auth_required");
767 assert_eq!(doc["error"], "AuthRequired");
768 assert_eq!(doc["reason"], "signed_out");
769 assert!(
770 doc["error"].is_string(),
771 "`error` must stay a string: {doc}"
772 );
773 assert!(doc.get("summary").is_none(), "summary leaked: {doc}");
775 assert_eq!(
776 doc["message"],
777 json!(super::super::agent_loop::AUTH_REQUIRED_SIGNED_OUT_MESSAGE)
778 );
779
780 let events = sink.0.lock().unwrap();
781 assert_eq!(events.len(), 1, "exactly one terminal event");
782 assert_eq!(events[0]["type"], "failed");
783 assert_eq!(events[0]["data"]["error"], "AuthRequired");
784 }
785
786 #[test]
791 fn the_json_suggestions_are_reason_specific_and_command_free() {
792 for (reason, expected) in [
793 (
794 AuthRequiredReason::SignedOut,
795 "Sign in to your Parslee account, then re-run.",
796 ),
797 (
798 AuthRequiredReason::Expired,
799 "Your Parslee sign-in has expired. Sign in again, then re-run.",
800 ),
801 (
802 AuthRequiredReason::NoWorkspace,
803 "Finish setting up your Parslee account at parslee.ai, then re-run.",
804 ),
805 ] {
806 let outcome = AssistantOutcome {
807 status: "auth_required",
808 summary: reason.remedy().to_string(),
809 turns: 1,
810 turns_completed: 0,
811 tools_called: vec![],
812 tool_receipts: vec![],
813 prior_receipts: 0,
814 models_served: vec![],
815 model_used: String::new(),
816 auth_required: Some(reason),
817 failure_cause: None,
818 };
819 let doc =
820 JsonEmitter::new(posture(), Arc::new(Captured::default())).finish(&outcome, None);
821 assert_eq!(doc["reason"], reason.as_str());
822 assert_eq!(
823 doc["suggestions"][0],
824 expected,
825 "wrong first suggestion for {}",
826 reason.as_str()
827 );
828 assert!(
833 !doc.to_string().contains("auth login"),
834 "no shell command belongs in a document read by programs and \
835 by hosts with their own sign-in entry: {doc}"
836 );
837 }
838 }
839
840 #[test]
844 fn the_auth_required_event_does_not_emit_a_second_terminal() {
845 let sink = Arc::new(Captured::default());
846 let emitter = JsonEmitter::new(posture(), sink.clone());
847 emitter.on_assistant_event(&AssistantEvent::AuthRequired {
848 reason: AuthRequiredReason::NoWorkspace,
849 message: super::super::agent_loop::AUTH_REQUIRED_NO_WORKSPACE_MESSAGE.into(),
850 });
851 assert!(sink.0.lock().unwrap().is_empty());
852 }
853
854 #[derive(Default)]
857 struct Captured(std::sync::Mutex<Vec<Value>>);
858
859 impl EventSink for Captured {
860 fn emit(&self, event: Value) {
861 self.0.lock().unwrap().push(event);
862 }
863 }
864
865 #[test]
873 fn events_go_to_the_sink_and_the_document_comes_back() {
874 let sink = Arc::new(Captured::default());
875 let emitter = JsonEmitter::new(posture(), sink.clone());
876 emitter.started("do the thing", "claude-opus-5");
877 emitter.on_assistant_event(&AssistantEvent::ModelServed {
878 model_id: "mlx/qwen3-4b:4bit".into(),
879 local_last_resort: true,
880 });
881 emitter.on_assistant_event(&AssistantEvent::ToolCall {
882 call_id: "test-call-1".into(),
883 sequence: 1,
884 name: "shell".into(),
885 params: json!({ "command": "ls" }),
886 });
887
888 let doc = emitter.finish(
889 &AssistantOutcome {
890 status: "success",
891 summary: "did the thing".into(),
892 turns: 2,
893 turns_completed: 2,
894 tools_called: vec!["shell".into()],
895 prior_receipts: 0,
896 tool_receipts: vec![AssistantToolReceipt {
897 tool: "shell".into(),
898 call_id: None,
899 sequence: None,
900 ok: true,
901 params: json!({ "command": "ls" }),
902 result: None,
903 via: None,
904 }],
905 models_served: vec![
906 AssistantModelAttribution {
907 model_id: "openai/gpt-5".into(),
908 local_last_resort: false,
909 },
910 AssistantModelAttribution {
911 model_id: "mlx/qwen3-4b:4bit".into(),
912 local_last_resort: true,
913 },
914 ],
915 model_used: "mlx/qwen3-4b:4bit".into(),
916 auth_required: None,
917 failure_cause: None,
918 },
919 None,
920 );
921
922 assert_eq!(doc["schema"], SCHEMA);
923 assert_eq!(doc["status"], "success");
924 assert_eq!(doc["summary"], "did the thing");
925 assert_eq!(doc["receipts"]["total"], 1);
926 assert_eq!(doc["sandbox"]["mode"], "docker");
927 assert_eq!(doc["model_used"], "mlx/qwen3-4b:4bit");
928 assert_eq!(doc["models_served"][0]["model_id"], "openai/gpt-5");
929 assert_eq!(doc["models_served"][1]["model_id"], "mlx/qwen3-4b:4bit");
930 assert_eq!(doc["models_served"][1]["local_last_resort"], true);
931
932 let events = sink.0.lock().unwrap().clone();
933 let types: Vec<&str> = events
934 .iter()
935 .map(|e| e["type"].as_str().unwrap_or_default())
936 .collect();
937 assert_eq!(
940 types,
941 vec!["started", "model_served", "tool_called", "completed"]
942 );
943 assert_eq!(events[1]["data"]["model_id"], "mlx/qwen3-4b:4bit");
944 assert_eq!(events[1]["data"]["local_last_resort"], true);
945 assert_eq!(events[2]["data"]["tool"], "shell");
946 assert_eq!(events[2]["data"]["brief"], "ls");
947 assert_eq!(
948 events[3]["data"]["models_served"], doc["models_served"],
949 "the terminal event and stdout run receipt must agree"
950 );
951 }
952
953 #[test]
954 fn goal_report_carries_grounded_separately_from_passed() {
955 let g = GoalReport {
958 check: "cargo test -q".into(),
959 passed: true,
960 grounded: false,
961 iterations: 3,
962 halt: None,
963 }
964 .to_json();
965 assert_eq!(g["passed"], true);
966 assert_eq!(g["grounded"], false);
967 }
968
969 #[test]
974 fn delegate_calls_are_briefed_by_goal_and_counted() {
975 let sink = Arc::new(Captured::default());
976 let emitter = JsonEmitter::new(posture(), sink.clone());
977 emitter.on_assistant_event(&AssistantEvent::ToolCall {
978 call_id: "test-call-2".into(),
979 sequence: 2,
980 name: super::super::agent_loop::DELEGATE_TOOL.into(),
981 params: json!({ "goal": "survey the repo layout", "tools": ["read_file"] }),
982 });
983 emitter.on_assistant_event(&AssistantEvent::ToolCall {
984 call_id: "test-call-3".into(),
985 sequence: 3,
986 name: super::super::agent_loop::DELEGATE_TOOL.into(),
987 params: json!({ "goal": "count the tests" }),
988 });
989 emitter.on_assistant_event(&AssistantEvent::ToolCall {
990 call_id: "test-call-4".into(),
991 sequence: 4,
992 name: "shell".into(),
993 params: json!({ "command": "ls" }),
994 });
995 let events = sink.0.lock().unwrap();
996 let first = events
997 .iter()
998 .find(|e| e["type"] == "tool_called")
999 .expect("a tool_called event");
1000 assert_eq!(first["data"]["brief"], "survey the repo layout");
1001 drop(events);
1002
1003 let doc = emitter.finish(
1004 &AssistantOutcome {
1005 status: "success",
1006 summary: "done".into(),
1007 turns: 3,
1008 turns_completed: 3,
1009 tools_called: vec![],
1010 tool_receipts: vec![],
1011 prior_receipts: 0,
1012 models_served: vec![],
1013 model_used: "m".into(),
1014 auth_required: None,
1015 failure_cause: None,
1016 },
1017 None,
1018 );
1019 assert_eq!(doc["turns"], 3);
1020 assert_eq!(doc["delegations"], 2);
1021 }
1022}