car_server_core/assistant/do_json.rs
1//! Machine-readable output for `car do --json`.
2//!
3//! ## Why two streams
4//!
5//! A host that delegates to `car do` — a Claude Code subagent, a Codex skill,
6//! any script — needs one thing it can parse and one thing it can narrate.
7//! Interleaving them into a single stream forces every consumer to re-derive
8//! the boundary, and consumers get it wrong.
9//!
10//! So: **stdout carries exactly one JSON document**, the final result, written
11//! once at the end. **stderr carries JSONL progress events**, one object per
12//! line, written as they happen. Nothing else is written to either stream in
13//! `--json` mode — the human progress rendering is suppressed.
14//!
15//! Neither Claude Code nor Codex streams a shell call, so in practice the
16//! events are read after the process exits. They still earn their place: they
17//! are how a caller explains a slow run and reports what was rejected
18//! mid-flight, rather than narrating from the final blob alone.
19//!
20//! ## Size is a correctness property, not a nicety
21//!
22//! Claude Code delegates into an isolated subagent context, so a large result
23//! costs that subagent's window and nothing else. **Codex has no
24//! plugin-authored subagent surface** — a `car do` delegation there runs as a
25//! skill on the main thread, and every byte lands in the user's primary
26//! context. An envelope sized for the Claude Code case poisons Codex sessions.
27//!
28//! Hence: the summary is capped, receipts are reported as counts plus a
29//! bounded sample rather than a transcript, and every truncation states what
30//! was elided instead of silently clipping (same reasoning as `cap()` in
31//! `car_server_core::assistant::agent_loop`).
32//!
33//! ## Contract stability
34//!
35//! The moment a plugin parses this, it is a public wire contract. It carries a
36//! version (`"schema": "car.do/1"`) and is documented in
37//! `docs/car-do-json.md`. Additive fields are compatible; removing or
38//! retyping one is not, and takes a new schema id.
39//!
40//! ## Why this lives in the runtime and not in the CLI
41//!
42//! It was `car-cli`'s until the daemon's MCP endpoint grew
43//! `assistant_start`/`assistant_poll` (car#972 §6), which needs the exact same
44//! envelope: an `assistant_poll` result IS this document, and a poll's event
45//! list IS these JSONL events. A second envelope would have been a second
46//! contract to version, and — more to the point — a second place for the
47//! `SUMMARY_CAP` / `RECEIPT_SAMPLE` truncation to be forgotten, when the reason
48//! that truncation exists (a caller pays for every byte in the user's context)
49//! applies to an MCP tool result verbatim.
50//!
51//! So the *shape* is here and the *destination* is the caller's:
52//! [`JsonEmitter`] builds values and hands them to an [`EventSink`], and
53//! [`JsonEmitter::finish`] returns the terminal document rather than printing
54//! it. `car do --json` supplies a stderr sink and prints the returned document
55//! to stdout; the MCP run registry supplies a buffer sink and returns the
56//! document as the tool result.
57
58use std::collections::BTreeMap;
59use std::sync::Arc;
60use std::time::Instant;
61
62use serde_json::{json, Value};
63
64use super::{AssistantEvent, AssistantOutcome, AssistantToolReceipt};
65
66/// Wire-contract version. Bump the major on any non-additive change.
67pub const SCHEMA: &str = "car.do/1";
68
69/// Cap on the `summary` field, in bytes.
70///
71/// Chosen for the Codex main-thread case above: a summary is a report, and a
72/// report that does not fit in a few thousand bytes is a transcript wearing a
73/// summary's name. Overflow states the elision rather than clipping silently.
74const SUMMARY_CAP: usize = 4096;
75
76/// Cap on a single `brief` string extracted from tool parameters.
77const BRIEF_CAP: usize = 160;
78
79/// How many receipts reach `receipts.sample`. Failures are selected first —
80/// a caller diagnosing a run needs the failures, and the successes are already
81/// summarized by `by_tool`.
82const RECEIPT_SAMPLE: usize = 8;
83
84/// Truncate to `cap` bytes on a char boundary, stating what was dropped.
85fn 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
103/// The one-line gist of a tool call, for events and receipt samples.
104///
105/// Deliberately NOT the full parameter object. Parameters carry file contents,
106/// request bodies, and whatever the model put in a shell command; forwarding
107/// them wholesale would blow the size budget and widen what leaves the process.
108/// The named keys are the ones that identify a call to a reader.
109fn brief(params: &Value) -> String {
110 // Ordered by how identifying the key is, and kept in sync with the
111 // assistant's actual tool parameters — a key missing here renders as an
112 // empty brief, which is what `calculate` did on the first end-to-end run.
113 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/// Sandbox posture, captured before the environment is consumed by the runtime.
133///
134/// Recorded rather than re-derived because it answers the question a reviewing
135/// human actually asks about an autonomous run — what could this have touched?
136#[derive(Clone)]
137pub struct SandboxPosture {
138 pub sandboxed: bool,
139 pub image: Option<String>,
140 pub tier: String,
141 pub root: String,
142 /// The sandbox mount when it is WIDER than `root` — a repository root the
143 /// run was widened to so git works (car#1269). `None` when the mount is
144 /// `root` itself, and on every local run.
145 pub mount: Option<String>,
146 pub fallback_notice: Option<String>,
147}
148
149impl SandboxPosture {
150 /// The `sandbox` block of the `car.do/1` document. Public so a caller can
151 /// report the bound posture before the run finishes — `assistant_start`
152 /// does, so a caller that asked for a sandbox and got the local host
153 /// learns it up front rather than in the terminal document.
154 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 // Present only when the container can reach MORE than `root`. A
162 // caller reporting on what an autonomous run could touch has to
163 // read this, not `root`, which is only where the run stands.
164 "mount": self.mount,
165 // Present only when the sandbox was requested and unavailable. A
166 // run that silently fell back to the local host is materially
167 // different from one that chose it, and the caller must be able to
168 // tell them apart.
169 "fallback_notice": self.fallback_notice,
170 })
171 }
172}
173
174/// Goal-mode outcome, present only for `--until` / `--infer-until` runs.
175pub struct GoalReport {
176 pub check: String,
177 pub passed: bool,
178 /// Whether completion was established by the deterministic check rather
179 /// than a model judge. A `passed: true, grounded: false` run completed on
180 /// a judge's say-so and must not be read as verified.
181 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
198/// Where a progress event goes once it has been built.
199///
200/// The two implementations are `car do --json`'s stderr writer and the MCP run
201/// registry's replay buffer. Split out so the event *shape* has one definition
202/// and the destination is the caller's business — an MCP poll returns the same
203/// objects `car do --json` writes as JSONL lines because they are built by the
204/// same code, not merely documented as equivalent.
205///
206/// `Send + Sync` because the MCP sink is written from a spawned run task.
207pub trait EventSink: Send + Sync {
208 /// Take one progress event: `{ type, phase, message, data }`.
209 fn emit(&self, event: Value);
210}
211
212/// Builds the progress events and the terminal document for one run.
213pub struct JsonEmitter {
214 started: Instant,
215 posture: SandboxPosture,
216 sink: Arc<dyn EventSink>,
217 /// `delegate` calls seen on this run. Counted from the parent's own
218 /// `ToolCall` events — a child's events never reach this emitter — so the
219 /// number is delegations issued, whatever each one then did.
220 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 /// Build one progress event and hand it to the sink.
234 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 /// Adapter for the assistant loop's `emit` callback.
257 ///
258 /// `Done` and `Error` are deliberately NOT emitted here. Exactly one of
259 /// `completed` / `failed` terminates the stream, and it is written by
260 /// [`Self::finish`] / [`Self::fail_run`] alongside the stdout document, so the
261 /// two can never disagree about how the run ended.
262 pub fn on_assistant_event(&self, ev: &AssistantEvent) {
263 match ev {
264 AssistantEvent::ModelServed {
265 model_id,
266 local_last_resort,
267 } => self.event(
268 "model_served",
269 "inference",
270 if *local_last_resort {
271 format!("{model_id} served via on-device last-resort fallback")
272 } else {
273 format!("{model_id} served")
274 },
275 json!({
276 "model_id": model_id,
277 "local_last_resort": local_last_resort,
278 }),
279 ),
280 AssistantEvent::Text(t) if !t.trim().is_empty() => {
281 self.event("text", "reasoning", cap_text(t, BRIEF_CAP * 4), json!({}))
282 }
283 AssistantEvent::Text(_) => {}
284 AssistantEvent::ToolCall { name, params } => {
285 if name == super::agent_loop::DELEGATE_TOOL {
286 self.delegations
287 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
288 }
289 self.event(
290 "tool_called",
291 "acting",
292 format!("{name}({})", brief(params)),
293 json!({ "tool": name, "brief": brief(params) }),
294 )
295 }
296 AssistantEvent::ToolResult { name, ok, .. } => self.event(
297 if *ok { "tool_result" } else { "tool_failed" },
298 "acting",
299 format!("{name} {}", if *ok { "ok" } else { "failed" }),
300 json!({ "tool": name, "ok": ok }),
301 ),
302 AssistantEvent::GoalEvaluated {
303 iteration,
304 met,
305 grounded,
306 reason,
307 } => self.event(
308 "goal_evaluated",
309 "verifying",
310 cap_text(reason, BRIEF_CAP * 2),
311 json!({
312 "iteration": iteration,
313 "met": met,
314 "grounded": grounded,
315 }),
316 ),
317 AssistantEvent::Done { .. } | AssistantEvent::Error(_) => {}
318 }
319 }
320
321 /// Roll receipts up into counts plus a bounded, failure-first sample.
322 fn receipts_json(receipts: &[AssistantToolReceipt]) -> Value {
323 let mut by_tool: BTreeMap<&str, u64> = BTreeMap::new();
324 let mut failed = 0u64;
325 for r in receipts {
326 *by_tool.entry(r.tool.as_str()).or_default() += 1;
327 if !r.ok {
328 failed += 1;
329 }
330 }
331 // Failures first: a caller diagnosing a run needs those, and `by_tool`
332 // already accounts for the successes.
333 let sample: Vec<Value> = receipts
334 .iter()
335 .filter(|r| !r.ok)
336 .chain(receipts.iter().filter(|r| r.ok))
337 .take(RECEIPT_SAMPLE)
338 .map(|r| match &r.via {
339 // A receipt a delegate child produced: say so, or the sample
340 // reads as the parent's own call.
341 Some(via) => {
342 json!({ "tool": r.tool, "ok": r.ok, "brief": brief(&r.params), "via": via })
343 }
344 None => json!({ "tool": r.tool, "ok": r.ok, "brief": brief(&r.params) }),
345 })
346 .collect();
347 let omitted = receipts.len().saturating_sub(sample.len());
348 json!({
349 "total": receipts.len(),
350 "failed": failed,
351 "by_tool": by_tool,
352 "sample": sample,
353 // Stated rather than implied. A sample that silently drops 40 calls
354 // reads as a complete list to anyone who does not check `total`.
355 "sample_omitted": omitted,
356 })
357 }
358
359 /// Emit the terminal event and return the document for a finished run.
360 ///
361 /// Returned rather than written: the caller decides whether it goes to
362 /// stdout (`car do --json`) or into a tool result (`assistant_poll`).
363 ///
364 /// A run whose loop errored is routed to the error shape, NOT reported as
365 /// a result with an apologetic summary. `AssistantOutcome` carries the
366 /// error text in `summary`, and a consumer that reads `summary` without
367 /// checking `status` would present a transport failure as the answer.
368 pub fn finish(&self, outcome: &AssistantOutcome, goal: Option<&GoalReport>) -> Value {
369 if outcome.status == "error" {
370 return self.fail_run(outcome);
371 }
372 let ungrounded = super::ungrounded_summary_claims(&outcome.summary, &outcome.tool_receipts);
373 let elapsed = self.started.elapsed().as_secs_f64();
374
375 self.event(
376 "completed",
377 "run",
378 "run finished",
379 json!({
380 "status": outcome.status,
381 "turns": outcome.turns,
382 "models_served": outcome.models_served,
383 "elapsed_seconds": elapsed,
384 }),
385 );
386
387 let mut doc = json!({
388 "schema": SCHEMA,
389 "status": outcome.status,
390 "summary": cap_text(&outcome.summary, SUMMARY_CAP),
391 // The PARENT's turns only. A `delegate` child's turns and model
392 // calls are metered in the journal but not counted here; the
393 // number of delegations issued is reported beside it.
394 "turns": outcome.turns,
395 "delegations": self.delegations.load(std::sync::atomic::Ordering::Relaxed),
396 "model_used": outcome.model_used,
397 "models_served": outcome.models_served,
398 "receipts": Self::receipts_json(&outcome.tool_receipts),
399 // The differentiator. Operational claims in the prose with no
400 // matching receipt. An empty array means nothing was detected, not
401 // that the summary is verified — see `ungrounded_summary_claims`.
402 "ungrounded_claims": ungrounded,
403 "sandbox": self.posture.to_json(),
404 "elapsed_seconds": elapsed,
405 });
406 if let Some(g) = goal {
407 doc["goal"] = g.to_json();
408 }
409 doc
410 }
411
412 /// Emit the terminal `failed` event and return the error document.
413 ///
414 /// The error document has a **different shape** — `message` rather than
415 /// `summary` — so a consumer that reads `summary` without checking
416 /// `status` gets a missing key instead of quietly presenting a transport
417 /// failure as the run's answer.
418 ///
419 /// Receipts are still reported: a run that died on turn nine did real work
420 /// first, and that work is what the caller needs to reason about before
421 /// retrying.
422 fn fail_run(&self, outcome: &AssistantOutcome) -> Value {
423 let elapsed = self.started.elapsed().as_secs_f64();
424 self.event(
425 "failed",
426 "run",
427 cap_text(&outcome.summary, BRIEF_CAP * 2),
428 json!({
429 "error": "AssistantLoopFailed",
430 "turns": outcome.turns,
431 "models_served": outcome.models_served,
432 }),
433 );
434 json!({
435 "schema": SCHEMA,
436 "status": "error",
437 "error": "AssistantLoopFailed",
438 "message": cap_text(&outcome.summary, SUMMARY_CAP),
439 "turns": outcome.turns,
440 "model_used": outcome.model_used,
441 "models_served": outcome.models_served,
442 "receipts": Self::receipts_json(&outcome.tool_receipts),
443 "sandbox": self.posture.to_json(),
444 "elapsed_seconds": elapsed,
445 "suggestions": [
446 "Re-run the goal; the run failed mid-loop rather than completing with an answer.",
447 "Check `receipts` for what had already executed before the failure.",
448 ],
449 })
450 }
451}
452
453/// The error document for a failure with no run outcome to describe — a bad
454/// flag combination, an unresolvable working directory, a refused MCP start,
455/// or a run whose task died without producing an outcome at all.
456///
457/// Separate from [`JsonEmitter::finish`] because there is no run outcome to
458/// time or describe, and inventing a `SandboxPosture` to report would be a lie.
459/// It still carries `schema` and `status`, so the caller's invariant — a
460/// `--json` invocation always produces exactly one document — holds on the
461/// paths where no run ever existed.
462pub fn startup_error_doc(error: &str, message: &str, suggestions: &[&str]) -> Value {
463 json!({
464 "schema": SCHEMA,
465 "status": "error",
466 "error": error,
467 "message": message,
468 "suggestions": suggestions,
469 })
470}
471
472#[cfg(test)]
473mod tests {
474 use super::super::AssistantModelAttribution;
475 use super::*;
476
477 fn posture() -> SandboxPosture {
478 SandboxPosture {
479 sandboxed: true,
480 image: Some("python:3.11".into()),
481 tier: "SandboxEdit".into(),
482 root: "/work".into(),
483 mount: None,
484 fallback_notice: None,
485 }
486 }
487
488 #[test]
489 fn cap_text_states_what_it_dropped() {
490 let s = "x".repeat(100);
491 let out = cap_text(&s, 10);
492 assert!(out.starts_with(&"x".repeat(10)));
493 assert!(out.contains("90 elided"), "{out}");
494 }
495
496 #[test]
497 fn cap_text_leaves_short_input_untouched() {
498 assert_eq!(cap_text("short", 100), "short");
499 }
500
501 #[test]
502 fn cap_text_respects_char_boundaries() {
503 // Multi-byte input truncated mid-character must not panic.
504 let s = "é".repeat(50);
505 let out = cap_text(&s, 11);
506 assert!(out.contains("elided"), "{out}");
507 }
508
509 #[test]
510 fn brief_prefers_identifying_keys_and_flattens_newlines() {
511 let p = json!({ "command": "cargo test\n--quiet", "body": "…huge…" });
512 assert_eq!(brief(&p), "cargo test --quiet");
513 }
514
515 #[test]
516 fn brief_is_empty_when_no_identifying_key_is_present() {
517 assert_eq!(brief(&json!({ "body": "opaque" })), "");
518 }
519
520 #[test]
521 fn brief_covers_the_tools_that_do_not_take_a_command_or_path() {
522 // Regression: the first end-to-end run rendered `calculate()` with an
523 // empty brief because `expression` was not in the list.
524 assert_eq!(brief(&json!({ "expression": "17 * 23" })), "17 * 23");
525 assert_eq!(
526 brief(&json!({ "query": "rust lifetimes" })),
527 "rust lifetimes"
528 );
529 assert_eq!(
530 brief(&json!({ "subject": "deploy cadence" })),
531 "deploy cadence"
532 );
533 }
534
535 #[test]
536 fn receipts_roll_up_counts_and_put_failures_in_the_sample_first() {
537 let mut receipts: Vec<AssistantToolReceipt> = (0..20)
538 .map(|i| AssistantToolReceipt {
539 tool: "shell".into(),
540 call_id: None,
541 ok: true,
542 params: json!({ "command": format!("ok-{i}") }),
543 via: None,
544 })
545 .collect();
546 receipts.push(AssistantToolReceipt {
547 tool: "write_file".into(),
548 call_id: None,
549 ok: false,
550 params: json!({ "path": "/denied" }),
551 via: None,
552 });
553
554 let v = JsonEmitter::receipts_json(&receipts);
555 assert_eq!(v["total"], 21);
556 assert_eq!(v["failed"], 1);
557 assert_eq!(v["by_tool"]["shell"], 20);
558 assert_eq!(v["by_tool"]["write_file"], 1);
559 // The failure leads, so a caller reading only the sample sees it.
560 assert_eq!(v["sample"][0]["tool"], "write_file");
561 assert_eq!(v["sample"].as_array().unwrap().len(), RECEIPT_SAMPLE);
562 // And the drop is stated rather than implied.
563 assert_eq!(v["sample_omitted"], 21 - RECEIPT_SAMPLE);
564 }
565
566 #[test]
567 fn sandbox_posture_distinguishes_a_fallback_from_a_choice() {
568 let chosen = posture().to_json();
569 assert_eq!(chosen["mode"], "docker");
570 assert_eq!(chosen["network"], "none");
571 assert!(chosen["fallback_notice"].is_null());
572
573 let fell_back = SandboxPosture {
574 sandboxed: false,
575 image: None,
576 fallback_notice: Some("Docker not running".into()),
577 ..posture()
578 }
579 .to_json();
580 assert_eq!(fell_back["mode"], "local");
581 assert_eq!(fell_back["network"], "host");
582 assert_eq!(fell_back["fallback_notice"], "Docker not running");
583 }
584
585 #[test]
586 fn an_errored_run_is_not_reported_as_a_result() {
587 // The guard that matters: `summary` must be absent on the error shape,
588 // so a consumer reading it without checking `status` fails loudly
589 // rather than presenting a transport failure as the answer.
590 let outcome = AssistantOutcome {
591 status: "error",
592 summary: "connection reset by peer".into(),
593 turns: 9,
594 tools_called: vec![],
595 tool_receipts: vec![AssistantToolReceipt {
596 tool: "shell".into(),
597 call_id: None,
598 ok: true,
599 params: json!({ "command": "ls" }),
600 via: None,
601 }],
602 models_served: vec![AssistantModelAttribution {
603 model_id: "claude-opus-5".into(),
604 local_last_resort: false,
605 }],
606 model_used: "claude-opus-5".into(),
607 };
608 let sink = Arc::new(Captured::default());
609 let doc = JsonEmitter::new(posture(), sink.clone()).finish(&outcome, None);
610 assert_eq!(doc["status"], "error");
611 assert!(doc.get("summary").is_none(), "summary leaked: {doc}");
612 assert_eq!(doc["message"], "connection reset by peer");
613 // Work done before the failure is still reported.
614 assert_eq!(doc["receipts"]["total"], 1);
615 assert_eq!(doc["models_served"][0]["model_id"], "claude-opus-5");
616 let events = sink.0.lock().unwrap();
617 assert_eq!(events.len(), 1);
618 assert_eq!(events[0]["type"], "failed");
619 assert_eq!(events[0]["data"]["models_served"], doc["models_served"]);
620 }
621
622 /// Keeps every event the emitter produces, so a test can read the stream
623 /// without a subprocess.
624 #[derive(Default)]
625 struct Captured(std::sync::Mutex<Vec<Value>>);
626
627 impl EventSink for Captured {
628 fn emit(&self, event: Value) {
629 self.0.lock().unwrap().push(event);
630 }
631 }
632
633 /// The split that lets one envelope serve two destinations: events go to
634 /// the sink, and the terminal document is RETURNED rather than printed.
635 ///
636 /// Before car#972 §6 this wrote straight to stderr and stdout, so the only
637 /// way to assert the document was to mirror it in the test — which is
638 /// exactly what `error_doc_for` above still has to do for the failure
639 /// shape, and why that mirror is a liability rather than a pattern to copy.
640 #[test]
641 fn events_go_to_the_sink_and_the_document_comes_back() {
642 let sink = Arc::new(Captured::default());
643 let emitter = JsonEmitter::new(posture(), sink.clone());
644 emitter.started("do the thing", "claude-opus-5");
645 emitter.on_assistant_event(&AssistantEvent::ModelServed {
646 model_id: "mlx/qwen3-4b:4bit".into(),
647 local_last_resort: true,
648 });
649 emitter.on_assistant_event(&AssistantEvent::ToolCall {
650 name: "shell".into(),
651 params: json!({ "command": "ls" }),
652 });
653
654 let doc = emitter.finish(
655 &AssistantOutcome {
656 status: "success",
657 summary: "did the thing".into(),
658 turns: 2,
659 tools_called: vec!["shell".into()],
660 tool_receipts: vec![AssistantToolReceipt {
661 tool: "shell".into(),
662 call_id: None,
663 ok: true,
664 params: json!({ "command": "ls" }),
665 via: None,
666 }],
667 models_served: vec![
668 AssistantModelAttribution {
669 model_id: "openai/gpt-5".into(),
670 local_last_resort: false,
671 },
672 AssistantModelAttribution {
673 model_id: "mlx/qwen3-4b:4bit".into(),
674 local_last_resort: true,
675 },
676 ],
677 model_used: "mlx/qwen3-4b:4bit".into(),
678 },
679 None,
680 );
681
682 assert_eq!(doc["schema"], SCHEMA);
683 assert_eq!(doc["status"], "success");
684 assert_eq!(doc["summary"], "did the thing");
685 assert_eq!(doc["receipts"]["total"], 1);
686 assert_eq!(doc["sandbox"]["mode"], "docker");
687 assert_eq!(doc["model_used"], "mlx/qwen3-4b:4bit");
688 assert_eq!(doc["models_served"][0]["model_id"], "openai/gpt-5");
689 assert_eq!(doc["models_served"][1]["model_id"], "mlx/qwen3-4b:4bit");
690 assert_eq!(doc["models_served"][1]["local_last_resort"], true);
691
692 let events = sink.0.lock().unwrap().clone();
693 let types: Vec<&str> = events
694 .iter()
695 .map(|e| e["type"].as_str().unwrap_or_default())
696 .collect();
697 // Exactly one terminal event, written alongside the document so the two
698 // can never disagree about how the run ended.
699 assert_eq!(
700 types,
701 vec!["started", "model_served", "tool_called", "completed"]
702 );
703 assert_eq!(events[1]["data"]["model_id"], "mlx/qwen3-4b:4bit");
704 assert_eq!(events[1]["data"]["local_last_resort"], true);
705 assert_eq!(events[2]["data"]["tool"], "shell");
706 assert_eq!(events[2]["data"]["brief"], "ls");
707 assert_eq!(
708 events[3]["data"]["models_served"], doc["models_served"],
709 "the terminal event and stdout run receipt must agree"
710 );
711 }
712
713 #[test]
714 fn goal_report_carries_grounded_separately_from_passed() {
715 // A judge-completed run: passed, but not grounded. Callers must be able
716 // to tell it apart from a deterministic pass.
717 let g = GoalReport {
718 check: "cargo test -q".into(),
719 passed: true,
720 grounded: false,
721 iterations: 3,
722 halt: None,
723 }
724 .to_json();
725 assert_eq!(g["passed"], true);
726 assert_eq!(g["grounded"], false);
727 }
728
729 /// A `delegate` call is one `tool_called` event whose brief is the GOAL
730 /// (the bug class the `IDENTIFYING` comment records: a key missing there
731 /// renders an empty brief), and the document counts delegations issued
732 /// beside the parent's turns.
733 #[test]
734 fn delegate_calls_are_briefed_by_goal_and_counted() {
735 let sink = Arc::new(Captured::default());
736 let emitter = JsonEmitter::new(posture(), sink.clone());
737 emitter.on_assistant_event(&AssistantEvent::ToolCall {
738 name: super::super::agent_loop::DELEGATE_TOOL.into(),
739 params: json!({ "goal": "survey the repo layout", "tools": ["read_file"] }),
740 });
741 emitter.on_assistant_event(&AssistantEvent::ToolCall {
742 name: super::super::agent_loop::DELEGATE_TOOL.into(),
743 params: json!({ "goal": "count the tests" }),
744 });
745 emitter.on_assistant_event(&AssistantEvent::ToolCall {
746 name: "shell".into(),
747 params: json!({ "command": "ls" }),
748 });
749 let events = sink.0.lock().unwrap();
750 let first = events
751 .iter()
752 .find(|e| e["type"] == "tool_called")
753 .expect("a tool_called event");
754 assert_eq!(first["data"]["brief"], "survey the repo layout");
755 drop(events);
756
757 let doc = emitter.finish(
758 &AssistantOutcome {
759 status: "success",
760 summary: "done".into(),
761 turns: 3,
762 tools_called: vec![],
763 tool_receipts: vec![],
764 models_served: vec![],
765 model_used: "m".into(),
766 },
767 None,
768 );
769 assert_eq!(doc["turns"], 3);
770 assert_eq!(doc["delegations"], 2);
771 }
772}