Skip to main content

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}