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, AuthRequiredReason};
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::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            // Terminal events are written by `finish` / `fail_run` alongside
365            // the stdout document, for the reason in this method's doc comment:
366            // exactly one terminal event, and it cannot disagree with the
367            // document beside it.
368            AssistantEvent::Done { .. }
369            | AssistantEvent::Error(_)
370            | AssistantEvent::AuthRequired { .. } => {}
371        }
372    }
373
374    /// Roll receipts up into counts plus a bounded, failure-first sample.
375    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        // Failures first: a caller diagnosing a run needs those, and `by_tool`
385        // already accounts for the successes.
386        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                // A receipt a delegate child produced: say so, or the sample
393                // reads as the parent's own call.
394                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            // Stated rather than implied. A sample that silently drops 40 calls
407            // reads as a complete list to anyone who does not check `total`.
408            "sample_omitted": omitted,
409        })
410    }
411
412    /// Emit the terminal event and return the document for a finished run.
413    ///
414    /// Returned rather than written: the caller decides whether it goes to
415    /// stdout (`car do --json`) or into a tool result (`assistant_poll`).
416    ///
417    /// A run whose loop errored is routed to the error shape, NOT reported as
418    /// a result with an apologetic summary. `AssistantOutcome` carries the
419    /// error text in `summary`, and a consumer that reads `summary` without
420    /// checking `status` would present a transport failure as the answer.
421    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            // The PARENT's turns only. A `delegate` child's turns and model
445            // calls are metered in the journal but not counted here; the
446            // number of delegations issued is reported beside it.
447            "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            // The differentiator. Operational claims in the prose with no
453            // matching receipt. An empty array means nothing was detected, not
454            // that the summary is verified — see `ungrounded_summary_claims`.
455            "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    /// Emit the terminal `failed` event and return the error document.
466    ///
467    /// The error document has a **different shape** — `message` rather than
468    /// `summary` — so a consumer that reads `summary` without checking
469    /// `status` gets a missing key instead of quietly presenting a transport
470    /// failure as the run's answer.
471    ///
472    /// Receipts are still reported: a run that died on turn nine did real work
473    /// first, and that work is what the caller needs to reason about before
474    /// retrying.
475    fn fail_run(&self, outcome: &AssistantOutcome) -> Value {
476        let elapsed = self.started.elapsed().as_secs_f64();
477        // An account refusal takes the SAME shape — `message`, not `summary` —
478        // because it is equally not an answer, and a consumer that reads
479        // `summary` without checking `status` must still get a missing key.
480        // `status` and the additive `reason`/`message` are what tell it apart;
481        // `error` stays a plain string, as every other terminal document's
482        // does, so nothing has to learn a second type for that field.
483        // Reason-specific, and free of shell commands. `--json` is read by
484        // programs and by hosts that have their own sign-in entry; handing them
485        // a CLI incantation is the wrong instruction in the wrong place. And
486        // `no_workspace` is not repaired by signing in at all — the person
487        // already did.
488        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
556/// The error document for a failure with no run outcome to describe — a bad
557/// flag combination, an unresolvable working directory, a refused MCP start,
558/// or a run whose task died without producing an outcome at all.
559///
560/// Separate from [`JsonEmitter::finish`] because there is no run outcome to
561/// time or describe, and inventing a `SandboxPosture` to report would be a lie.
562/// It still carries `schema` and `status`, so the caller's invariant — a
563/// `--json` invocation always produces exactly one document — holds on the
564/// paths where no run ever existed.
565pub 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        // Multi-byte input truncated mid-character must not panic.
607        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        // Regression: the first end-to-end run rendered `calculate()` with an
626        // empty brief because `expression` was not in the list.
627        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        // The failure leads, so a caller reading only the sample sees it.
667        assert_eq!(v["sample"][0]["tool"], "write_file");
668        assert_eq!(v["sample"].as_array().unwrap().len(), RECEIPT_SAMPLE);
669        // And the drop is stated rather than implied.
670        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        // The guard that matters: `summary` must be absent on the error shape,
695        // so a consumer reading it without checking `status` fails loudly
696        // rather than presenting a transport failure as the answer.
697        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        // Work done before the failure is still reported.
735        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        // The document is still `car.do/1`: every field here is additive, and
764        // `error` is still a string, so nothing pinned to the schema breaks.
765        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        // Not an answer, so the failure shape's guard still applies.
774        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    /// The suggestion has to fit the reason, and it must not be a shell
787    /// command: this document is read by programs and by hosts with their own
788    /// sign-in entry, and a `no_workspace` account is not repaired by signing
789    /// in — the person already did.
790    #[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            // `auth login` rather than the full command, so a reworded
829            // prefix is caught too — and so a `git grep` for the command
830            // across this crate's `--json` surface stays genuinely empty
831            // rather than finding this assertion.
832            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    /// `on_assistant_event` must stay silent for the refusal, for the same
841    /// reason it is silent for `Done`/`Error`: exactly one terminal event, and
842    /// `finish` owns it so the event and the document cannot disagree.
843    #[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    /// Keeps every event the emitter produces, so a test can read the stream
855    /// without a subprocess.
856    #[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    /// The split that lets one envelope serve two destinations: events go to
866    /// the sink, and the terminal document is RETURNED rather than printed.
867    ///
868    /// Before car#972 §6 this wrote straight to stderr and stdout, so the only
869    /// way to assert the document was to mirror it in the test — which is
870    /// exactly what `error_doc_for` above still has to do for the failure
871    /// shape, and why that mirror is a liability rather than a pattern to copy.
872    #[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        // Exactly one terminal event, written alongside the document so the two
938        // can never disagree about how the run ended.
939        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        // A judge-completed run: passed, but not grounded. Callers must be able
956        // to tell it apart from a deterministic pass.
957        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    /// A `delegate` call is one `tool_called` event whose brief is the GOAL
970    /// (the bug class the `IDENTIFYING` comment records: a key missing there
971    /// renders an empty brief), and the document counts delegations issued
972    /// beside the parent's turns.
973    #[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}