car-server-core 0.49.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
//! Machine-readable output for `car do --json`.
//!
//! ## Why two streams
//!
//! A host that delegates to `car do` — a Claude Code subagent, a Codex skill,
//! any script — needs one thing it can parse and one thing it can narrate.
//! Interleaving them into a single stream forces every consumer to re-derive
//! the boundary, and consumers get it wrong.
//!
//! So: **stdout carries exactly one JSON document**, the final result, written
//! once at the end. **stderr carries JSONL progress events**, one object per
//! line, written as they happen. Nothing else is written to either stream in
//! `--json` mode — the human progress rendering is suppressed.
//!
//! Neither Claude Code nor Codex streams a shell call, so in practice the
//! events are read after the process exits. They still earn their place: they
//! are how a caller explains a slow run and reports what was rejected
//! mid-flight, rather than narrating from the final blob alone.
//!
//! ## Size is a correctness property, not a nicety
//!
//! Claude Code delegates into an isolated subagent context, so a large result
//! costs that subagent's window and nothing else. **Codex has no
//! plugin-authored subagent surface** — a `car do` delegation there runs as a
//! skill on the main thread, and every byte lands in the user's primary
//! context. An envelope sized for the Claude Code case poisons Codex sessions.
//!
//! Hence: the summary is capped, receipts are reported as counts plus a
//! bounded sample rather than a transcript, and every truncation states what
//! was elided instead of silently clipping (same reasoning as `cap()` in
//! `car_server_core::assistant::agent_loop`).
//!
//! ## Contract stability
//!
//! The moment a plugin parses this, it is a public wire contract. It carries a
//! version (`"schema": "car.do/1"`) and is documented in
//! `docs/car-do-json.md`. Additive fields are compatible; removing or
//! retyping one is not, and takes a new schema id.
//!
//! ## Why this lives in the runtime and not in the CLI
//!
//! It was `car-cli`'s until the daemon's MCP endpoint grew
//! `assistant_start`/`assistant_poll` (car#972 §6), which needs the exact same
//! envelope: an `assistant_poll` result IS this document, and a poll's event
//! list IS these JSONL events. A second envelope would have been a second
//! contract to version, and — more to the point — a second place for the
//! `SUMMARY_CAP` / `RECEIPT_SAMPLE` truncation to be forgotten, when the reason
//! that truncation exists (a caller pays for every byte in the user's context)
//! applies to an MCP tool result verbatim.
//!
//! So the *shape* is here and the *destination* is the caller's:
//! [`JsonEmitter`] builds values and hands them to an [`EventSink`], and
//! [`JsonEmitter::finish`] returns the terminal document rather than printing
//! it. `car do --json` supplies a stderr sink and prints the returned document
//! to stdout; the MCP run registry supplies a buffer sink and returns the
//! document as the tool result.

use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Instant;

use serde_json::{json, Value};

use super::{AssistantEvent, AssistantOutcome, AssistantToolReceipt};

/// Wire-contract version. Bump the major on any non-additive change.
pub const SCHEMA: &str = "car.do/1";

/// Cap on the `summary` field, in bytes.
///
/// Chosen for the Codex main-thread case above: a summary is a report, and a
/// report that does not fit in a few thousand bytes is a transcript wearing a
/// summary's name. Overflow states the elision rather than clipping silently.
const SUMMARY_CAP: usize = 4096;

/// Cap on a single `brief` string extracted from tool parameters.
const BRIEF_CAP: usize = 160;

/// How many receipts reach `receipts.sample`. Failures are selected first —
/// a caller diagnosing a run needs the failures, and the successes are already
/// summarized by `by_tool`.
const RECEIPT_SAMPLE: usize = 8;

/// Truncate to `cap` bytes on a char boundary, stating what was dropped.
fn cap_text(s: &str, cap: usize) -> String {
    if s.len() <= cap {
        return s.to_string();
    }
    let mut end = cap;
    while !s.is_char_boundary(end) {
        end -= 1;
    }
    let elided = s.len() - end;
    format!(
        "{}\n…[truncated: {} of {} bytes shown; {} elided]…",
        &s[..end],
        end,
        s.len(),
        elided
    )
}

/// The one-line gist of a tool call, for events and receipt samples.
///
/// Deliberately NOT the full parameter object. Parameters carry file contents,
/// request bodies, and whatever the model put in a shell command; forwarding
/// them wholesale would blow the size budget and widen what leaves the process.
/// The named keys are the ones that identify a call to a reader.
fn brief(params: &Value) -> String {
    // Ordered by how identifying the key is, and kept in sync with the
    // assistant's actual tool parameters — a key missing here renders as an
    // empty brief, which is what `calculate` did on the first end-to-end run.
    const IDENTIFYING: &[&str] = &[
        "command",
        "path",
        "url",
        "query",
        "expression",
        "subject",
        "name",
        "content",
    ];
    let raw = IDENTIFYING
        .iter()
        .find_map(|k| params.get(*k).and_then(Value::as_str))
        .unwrap_or_default()
        .replace('\n', " ");
    cap_text(&raw, BRIEF_CAP)
}

/// Sandbox posture, captured before the environment is consumed by the runtime.
///
/// Recorded rather than re-derived because it answers the question a reviewing
/// human actually asks about an autonomous run — what could this have touched?
#[derive(Clone)]
pub struct SandboxPosture {
    pub sandboxed: bool,
    pub image: Option<String>,
    pub tier: String,
    pub root: String,
    pub fallback_notice: Option<String>,
}

impl SandboxPosture {
    /// The `sandbox` block of the `car.do/1` document. Public so a caller can
    /// report the bound posture before the run finishes — `assistant_start`
    /// does, so a caller that asked for a sandbox and got the local host
    /// learns it up front rather than in the terminal document.
    pub fn to_json(&self) -> Value {
        json!({
            "mode": if self.sandboxed { "docker" } else { "local" },
            "image": self.image,
            "network": if self.sandboxed { "none" } else { "host" },
            "tier": self.tier,
            "root": self.root,
            // Present only when the sandbox was requested and unavailable. A
            // run that silently fell back to the local host is materially
            // different from one that chose it, and the caller must be able to
            // tell them apart.
            "fallback_notice": self.fallback_notice,
        })
    }
}

/// Goal-mode outcome, present only for `--until` / `--infer-until` runs.
pub struct GoalReport {
    pub check: String,
    pub passed: bool,
    /// Whether completion was established by the deterministic check rather
    /// than a model judge. A `passed: true, grounded: false` run completed on
    /// a judge's say-so and must not be read as verified.
    pub grounded: bool,
    pub iterations: u32,
    pub halt: Option<String>,
}

impl GoalReport {
    fn to_json(&self) -> Value {
        json!({
            "check": self.check,
            "passed": self.passed,
            "grounded": self.grounded,
            "iterations": self.iterations,
            "halt": self.halt,
        })
    }
}

/// Where a progress event goes once it has been built.
///
/// The two implementations are `car do --json`'s stderr writer and the MCP run
/// registry's replay buffer. Split out so the event *shape* has one definition
/// and the destination is the caller's business — an MCP poll returns the same
/// objects `car do --json` writes as JSONL lines because they are built by the
/// same code, not merely documented as equivalent.
///
/// `Send + Sync` because the MCP sink is written from a spawned run task.
pub trait EventSink: Send + Sync {
    /// Take one progress event: `{ type, phase, message, data }`.
    fn emit(&self, event: Value);
}

/// Builds the progress events and the terminal document for one run.
pub struct JsonEmitter {
    started: Instant,
    posture: SandboxPosture,
    sink: Arc<dyn EventSink>,
}

impl JsonEmitter {
    pub fn new(posture: SandboxPosture, sink: Arc<dyn EventSink>) -> Self {
        Self {
            started: Instant::now(),
            posture,
            sink,
        }
    }

    /// Build one progress event and hand it to the sink.
    fn event(&self, ty: &str, phase: &str, message: impl Into<String>, data: Value) {
        self.sink.emit(json!({
            "type": ty,
            "phase": phase,
            "message": message.into(),
            "data": data,
        }));
    }

    pub fn started(&self, goal: &str, model: &str) {
        self.event(
            "started",
            "run",
            "run started",
            json!({
                "goal": cap_text(goal, BRIEF_CAP),
                "model": model,
                "sandbox": self.posture.to_json(),
            }),
        );
    }

    /// Adapter for the assistant loop's `emit` callback.
    ///
    /// `Done` and `Error` are deliberately NOT emitted here. Exactly one of
    /// `completed` / `failed` terminates the stream, and it is written by
    /// [`Self::finish`] / [`Self::fail`] alongside the stdout document, so the
    /// two can never disagree about how the run ended.
    pub fn on_assistant_event(&self, ev: &AssistantEvent) {
        match ev {
            AssistantEvent::Text(t) if !t.trim().is_empty() => {
                self.event("text", "reasoning", cap_text(t, BRIEF_CAP * 4), json!({}))
            }
            AssistantEvent::Text(_) => {}
            AssistantEvent::ToolCall { name, params } => self.event(
                "tool_called",
                "acting",
                format!("{name}({})", brief(params)),
                json!({ "tool": name, "brief": brief(params) }),
            ),
            AssistantEvent::ToolResult { name, ok, .. } => self.event(
                if *ok { "tool_result" } else { "tool_failed" },
                "acting",
                format!("{name} {}", if *ok { "ok" } else { "failed" }),
                json!({ "tool": name, "ok": ok }),
            ),
            AssistantEvent::GoalEvaluated {
                iteration,
                met,
                grounded,
                reason,
            } => self.event(
                "goal_evaluated",
                "verifying",
                cap_text(reason, BRIEF_CAP * 2),
                json!({
                    "iteration": iteration,
                    "met": met,
                    "grounded": grounded,
                }),
            ),
            AssistantEvent::Done { .. } | AssistantEvent::Error(_) => {}
        }
    }

    /// Roll receipts up into counts plus a bounded, failure-first sample.
    fn receipts_json(receipts: &[AssistantToolReceipt]) -> Value {
        let mut by_tool: BTreeMap<&str, u64> = BTreeMap::new();
        let mut failed = 0u64;
        for r in receipts {
            *by_tool.entry(r.tool.as_str()).or_default() += 1;
            if !r.ok {
                failed += 1;
            }
        }
        // Failures first: a caller diagnosing a run needs those, and `by_tool`
        // already accounts for the successes.
        let sample: Vec<Value> = receipts
            .iter()
            .filter(|r| !r.ok)
            .chain(receipts.iter().filter(|r| r.ok))
            .take(RECEIPT_SAMPLE)
            .map(|r| json!({ "tool": r.tool, "ok": r.ok, "brief": brief(&r.params) }))
            .collect();
        let omitted = receipts.len().saturating_sub(sample.len());
        json!({
            "total": receipts.len(),
            "failed": failed,
            "by_tool": by_tool,
            "sample": sample,
            // Stated rather than implied. A sample that silently drops 40 calls
            // reads as a complete list to anyone who does not check `total`.
            "sample_omitted": omitted,
        })
    }

    /// Emit the terminal event and return the document for a finished run.
    ///
    /// Returned rather than written: the caller decides whether it goes to
    /// stdout (`car do --json`) or into a tool result (`assistant_poll`).
    ///
    /// A run whose loop errored is routed to the error shape, NOT reported as
    /// a result with an apologetic summary. `AssistantOutcome` carries the
    /// error text in `summary`, and a consumer that reads `summary` without
    /// checking `status` would present a transport failure as the answer.
    pub fn finish(&self, outcome: &AssistantOutcome, goal: Option<&GoalReport>) -> Value {
        if outcome.status == "error" {
            return self.fail_run(outcome);
        }
        let ungrounded = super::ungrounded_summary_claims(&outcome.summary, &outcome.tool_receipts);
        let elapsed = self.started.elapsed().as_secs_f64();

        self.event(
            "completed",
            "run",
            "run finished",
            json!({
                "status": outcome.status,
                "turns": outcome.turns,
                "elapsed_seconds": elapsed,
            }),
        );

        let mut doc = json!({
            "schema": SCHEMA,
            "status": outcome.status,
            "summary": cap_text(&outcome.summary, SUMMARY_CAP),
            "turns": outcome.turns,
            "model_used": outcome.model_used,
            "receipts": Self::receipts_json(&outcome.tool_receipts),
            // The differentiator. Operational claims in the prose with no
            // matching receipt. An empty array means nothing was detected, not
            // that the summary is verified — see `ungrounded_summary_claims`.
            "ungrounded_claims": ungrounded,
            "sandbox": self.posture.to_json(),
            "elapsed_seconds": elapsed,
        });
        if let Some(g) = goal {
            doc["goal"] = g.to_json();
        }
        doc
    }

    /// Emit the terminal `failed` event and return the error document.
    ///
    /// The error document has a **different shape** — `message` rather than
    /// `summary` — so a consumer that reads `summary` without checking
    /// `status` gets a missing key instead of quietly presenting a transport
    /// failure as the run's answer.
    ///
    /// Receipts are still reported: a run that died on turn nine did real work
    /// first, and that work is what the caller needs to reason about before
    /// retrying.
    fn fail_run(&self, outcome: &AssistantOutcome) -> Value {
        let elapsed = self.started.elapsed().as_secs_f64();
        self.event(
            "failed",
            "run",
            cap_text(&outcome.summary, BRIEF_CAP * 2),
            json!({ "error": "AssistantLoopFailed", "turns": outcome.turns }),
        );
        json!({
            "schema": SCHEMA,
            "status": "error",
            "error": "AssistantLoopFailed",
            "message": cap_text(&outcome.summary, SUMMARY_CAP),
            "turns": outcome.turns,
            "model_used": outcome.model_used,
            "receipts": Self::receipts_json(&outcome.tool_receipts),
            "sandbox": self.posture.to_json(),
            "elapsed_seconds": elapsed,
            "suggestions": [
                "Re-run the goal; the run failed mid-loop rather than completing with an answer.",
                "Check `receipts` for what had already executed before the failure.",
            ],
        })
    }
}

/// The error document for a failure with no run outcome to describe — a bad
/// flag combination, an unresolvable working directory, a refused MCP start,
/// or a run whose task died without producing an outcome at all.
///
/// Separate from [`JsonEmitter::finish`] because there is no run outcome to
/// time or describe, and inventing a `SandboxPosture` to report would be a lie.
/// It still carries `schema` and `status`, so the caller's invariant — a
/// `--json` invocation always produces exactly one document — holds on the
/// paths where no run ever existed.
pub fn startup_error_doc(error: &str, message: &str, suggestions: &[&str]) -> Value {
    json!({
        "schema": SCHEMA,
        "status": "error",
        "error": error,
        "message": message,
        "suggestions": suggestions,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn posture() -> SandboxPosture {
        SandboxPosture {
            sandboxed: true,
            image: Some("python:3.11".into()),
            tier: "SandboxEdit".into(),
            root: "/work".into(),
            fallback_notice: None,
        }
    }

    #[test]
    fn cap_text_states_what_it_dropped() {
        let s = "x".repeat(100);
        let out = cap_text(&s, 10);
        assert!(out.starts_with(&"x".repeat(10)));
        assert!(out.contains("90 elided"), "{out}");
    }

    #[test]
    fn cap_text_leaves_short_input_untouched() {
        assert_eq!(cap_text("short", 100), "short");
    }

    #[test]
    fn cap_text_respects_char_boundaries() {
        // Multi-byte input truncated mid-character must not panic.
        let s = "é".repeat(50);
        let out = cap_text(&s, 11);
        assert!(out.contains("elided"), "{out}");
    }

    #[test]
    fn brief_prefers_identifying_keys_and_flattens_newlines() {
        let p = json!({ "command": "cargo test\n--quiet", "body": "…huge…" });
        assert_eq!(brief(&p), "cargo test --quiet");
    }

    #[test]
    fn brief_is_empty_when_no_identifying_key_is_present() {
        assert_eq!(brief(&json!({ "body": "opaque" })), "");
    }

    #[test]
    fn brief_covers_the_tools_that_do_not_take_a_command_or_path() {
        // Regression: the first end-to-end run rendered `calculate()` with an
        // empty brief because `expression` was not in the list.
        assert_eq!(brief(&json!({ "expression": "17 * 23" })), "17 * 23");
        assert_eq!(
            brief(&json!({ "query": "rust lifetimes" })),
            "rust lifetimes"
        );
        assert_eq!(
            brief(&json!({ "subject": "deploy cadence" })),
            "deploy cadence"
        );
    }

    #[test]
    fn receipts_roll_up_counts_and_put_failures_in_the_sample_first() {
        let mut receipts: Vec<AssistantToolReceipt> = (0..20)
            .map(|i| AssistantToolReceipt {
                tool: "shell".into(),
                call_id: None,
                ok: true,
                params: json!({ "command": format!("ok-{i}") }),
            })
            .collect();
        receipts.push(AssistantToolReceipt {
            tool: "write_file".into(),
            call_id: None,
            ok: false,
            params: json!({ "path": "/denied" }),
        });

        let v = JsonEmitter::receipts_json(&receipts);
        assert_eq!(v["total"], 21);
        assert_eq!(v["failed"], 1);
        assert_eq!(v["by_tool"]["shell"], 20);
        assert_eq!(v["by_tool"]["write_file"], 1);
        // The failure leads, so a caller reading only the sample sees it.
        assert_eq!(v["sample"][0]["tool"], "write_file");
        assert_eq!(v["sample"].as_array().unwrap().len(), RECEIPT_SAMPLE);
        // And the drop is stated rather than implied.
        assert_eq!(v["sample_omitted"], 21 - RECEIPT_SAMPLE);
    }

    #[test]
    fn sandbox_posture_distinguishes_a_fallback_from_a_choice() {
        let chosen = posture().to_json();
        assert_eq!(chosen["mode"], "docker");
        assert_eq!(chosen["network"], "none");
        assert!(chosen["fallback_notice"].is_null());

        let fell_back = SandboxPosture {
            sandboxed: false,
            image: None,
            fallback_notice: Some("Docker not running".into()),
            ..posture()
        }
        .to_json();
        assert_eq!(fell_back["mode"], "local");
        assert_eq!(fell_back["network"], "host");
        assert_eq!(fell_back["fallback_notice"], "Docker not running");
    }

    #[test]
    fn an_errored_run_is_not_reported_as_a_result() {
        // The guard that matters: `summary` must be absent on the error shape,
        // so a consumer reading it without checking `status` fails loudly
        // rather than presenting a transport failure as the answer.
        let outcome = AssistantOutcome {
            status: "error",
            summary: "connection reset by peer".into(),
            turns: 9,
            tools_called: vec![],
            tool_receipts: vec![AssistantToolReceipt {
                tool: "shell".into(),
                call_id: None,
                ok: true,
                params: json!({ "command": "ls" }),
            }],
            model_used: "claude-opus-5".into(),
        };
        let doc = error_doc_for(&outcome);
        assert_eq!(doc["status"], "error");
        assert!(doc.get("summary").is_none(), "summary leaked: {doc}");
        assert_eq!(doc["message"], "connection reset by peer");
        // Work done before the failure is still reported.
        assert_eq!(doc["receipts"]["total"], 1);
    }

    /// Mirror of `fail_run`'s document, so the shape can be asserted without
    /// capturing stdout. Kept adjacent to it deliberately — if one changes and
    /// the other does not, the test above stops meaning anything.
    fn error_doc_for(outcome: &AssistantOutcome) -> Value {
        json!({
            "schema": SCHEMA,
            "status": "error",
            "error": "AssistantLoopFailed",
            "message": cap_text(&outcome.summary, SUMMARY_CAP),
            "turns": outcome.turns,
            "model_used": outcome.model_used,
            "receipts": JsonEmitter::receipts_json(&outcome.tool_receipts),
        })
    }

    /// Keeps every event the emitter produces, so a test can read the stream
    /// without a subprocess.
    #[derive(Default)]
    struct Captured(std::sync::Mutex<Vec<Value>>);

    impl EventSink for Captured {
        fn emit(&self, event: Value) {
            self.0.lock().unwrap().push(event);
        }
    }

    /// The split that lets one envelope serve two destinations: events go to
    /// the sink, and the terminal document is RETURNED rather than printed.
    ///
    /// Before car#972 §6 this wrote straight to stderr and stdout, so the only
    /// way to assert the document was to mirror it in the test — which is
    /// exactly what `error_doc_for` above still has to do for the failure
    /// shape, and why that mirror is a liability rather than a pattern to copy.
    #[test]
    fn events_go_to_the_sink_and_the_document_comes_back() {
        let sink = Arc::new(Captured::default());
        let emitter = JsonEmitter::new(posture(), sink.clone());
        emitter.started("do the thing", "claude-opus-5");
        emitter.on_assistant_event(&AssistantEvent::ToolCall {
            name: "shell".into(),
            params: json!({ "command": "ls" }),
        });

        let doc = emitter.finish(
            &AssistantOutcome {
                status: "success",
                summary: "did the thing".into(),
                turns: 2,
                tools_called: vec!["shell".into()],
                tool_receipts: vec![AssistantToolReceipt {
                    tool: "shell".into(),
                    call_id: None,
                    ok: true,
                    params: json!({ "command": "ls" }),
                }],
                model_used: "claude-opus-5".into(),
            },
            None,
        );

        assert_eq!(doc["schema"], SCHEMA);
        assert_eq!(doc["status"], "success");
        assert_eq!(doc["summary"], "did the thing");
        assert_eq!(doc["receipts"]["total"], 1);
        assert_eq!(doc["sandbox"]["mode"], "docker");

        let events = sink.0.lock().unwrap().clone();
        let types: Vec<&str> = events
            .iter()
            .map(|e| e["type"].as_str().unwrap_or_default())
            .collect();
        // Exactly one terminal event, written alongside the document so the two
        // can never disagree about how the run ended.
        assert_eq!(types, vec!["started", "tool_called", "completed"]);
        assert_eq!(events[1]["data"]["tool"], "shell");
        assert_eq!(events[1]["data"]["brief"], "ls");
    }

    #[test]
    fn goal_report_carries_grounded_separately_from_passed() {
        // A judge-completed run: passed, but not grounded. Callers must be able
        // to tell it apart from a deterministic pass.
        let g = GoalReport {
            check: "cargo test -q".into(),
            passed: true,
            grounded: false,
            iterations: 3,
            halt: None,
        }
        .to_json();
        assert_eq!(g["passed"], true);
        assert_eq!(g["grounded"], false);
    }
}