Skip to main content

harness/
events.rs

1//! Normalized run events — the one shape the UI consumes regardless
2//! of which harness produced them.
3//!
4//! Every adapter (bob's stream-json, Claude Code's stream-json,
5//! Codex's format, a raw-API agent loop) parses its own wire format
6//! into these variants *on the Rust side*. The front-end then learns
7//! exactly one event vocabulary and never grows a per-harness
8//! parser. This is the keystone of the harness abstraction: the cost
9//! of adding a harness is "write a parser into `RunEvent`," not
10//! "teach the UI another format."
11//!
12//! Suggested edits carry only the *raw* edit (path + byte range +
13//! replacement). Turning those into previewable drafts needs the
14//! workspace file content and the coordinate mapper, which live in
15//! the consuming app layer, so that step stays there — this module's
16//! job is just to lift the edit out of the harness's bespoke wire
17//! format.
18
19use serde::Serialize;
20
21use cli_stream::ProcessEvent;
22
23/// A UTF-8 byte range into a document. Mirrors the persisted
24/// `ByteOffset` discipline (see `docs/editor-guide.md`): positions
25/// crossing the harness boundary are bytes, never code units.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct ByteRange {
29    pub start: u64,
30    pub end: u64,
31}
32
33/// A raw suggested edit emitted by a harness. The app layer prepares
34/// these into previewable drafts; this is the transport shape.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct SuggestedEdit {
38    pub file_path: String,
39    pub range: ByteRange,
40    pub replacement: String,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub title: Option<String>,
43}
44
45/// A neutral, cross-harness classification of what a tool call *does* — so a
46/// consumer can route by behaviour (a read → a context pill, an edit → a
47/// file-op card) without re-encoding each harness's native tool vocabulary
48/// (bob's `read_file`, Claude's `Read`, codex's `file_change`). The raw
49/// `name` is kept alongside for display/phrasing; `tool_kind` is for
50/// behaviour. Named `tool_kind` (not `kind`) so it never collides with the
51/// `#[serde(tag = "kind")]` event discriminator on [`RunEvent`].
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "camelCase")]
54#[non_exhaustive]
55pub enum ToolKind {
56    /// Read or inspect a file's contents.
57    Read,
58    /// Create or overwrite a whole file.
59    Write,
60    /// Modify part of an existing file.
61    Edit,
62    /// Search or list files / the web.
63    Search,
64    /// Run a shell command or external process.
65    Execute,
66    /// Anything else (MCP calls, task spawns, completion signals, …).
67    Other,
68}
69
70/// A tool call beginning — its id + name, so the UI can render a
71/// state-ful card (running → done/✗) keyed by `tool_call_id`. `input`
72/// carries the call's arguments when the harness delivers them inline
73/// at the start (bob's `parameters`, codex's `command`); it is `None`
74/// when the harness streams them incrementally (Claude's
75/// `input_json_delta`), so the card stays correct either way. `tool_kind`
76/// is the neutral behaviour class (see [`ToolKind`]).
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct ToolCallStart {
80    pub tool_call_id: String,
81    pub name: String,
82    pub input: Option<String>,
83    pub tool_kind: ToolKind,
84}
85
86/// A tool call finishing — matched to its start by `tool_call_id`.
87/// `output` carries the tool's result when the harness reports it
88/// inline at completion (bob's `tool_result.output`, codex's
89/// `aggregated_output`, Claude's `tool_result.content`).
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
91#[serde(rename_all = "camelCase")]
92pub struct ToolCallEnd {
93    pub tool_call_id: String,
94    pub ok: bool,
95    pub output: Option<String>,
96}
97
98/// The normalized event stream. `#[serde(tag = "kind")]` +
99/// camelCase mirrors the existing `ProcessEvent` wire contract the TS
100/// store already reads (`event.kind`, `event.runId`, …), so the
101/// front-end consumes one shape regardless of which harness produced it.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103// `rename_all` camelCases the variant tags ("suggestedEdits"); serde
104// does NOT cascade that to struct-variant fields, so `rename_all_fields`
105// is required to get `runId` / `exitCode` on the wire rather than the
106// snake_case Rust idents.
107#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
108// New event kinds (a richer Usage, a new lifecycle signal, …) can be added
109// without breaking consumers — they must carry a `_` arm. Adding `Session` /
110// `Usage` earlier was a breaking change precisely because this was missing.
111#[non_exhaustive]
112pub enum RunEvent {
113    /// First event, before any output. UI shows "thinking…". Fired the
114    /// instant the process spawns — *before* the CLI reports its
115    /// session/model, which arrive separately as [`RunEvent::Session`].
116    Started { run_id: String },
117    /// The agent session is established — its id and the model in use.
118    /// Distinct from `Started` because it arrives a beat later, in the
119    /// CLI's first output line (bob's `init`, Claude's `system/init`,
120    /// codex's `thread.started`); keeping `Started` instant matters for
121    /// the "thinking…" feedback. Either field may be absent when the CLI
122    /// doesn't report it (e.g. codex gives a thread id but no model).
123    Session {
124        run_id: String,
125        #[serde(skip_serializing_if = "Option::is_none")]
126        session_id: Option<String>,
127        #[serde(skip_serializing_if = "Option::is_none")]
128        model: Option<String>,
129    },
130    /// A chunk of assistant text. Appended to the active message.
131    Text { run_id: String, delta: String },
132    /// A chunk of model reasoning ("thinking"), rendered distinctly from
133    /// `Text` so the UI can show reasoning without mixing it into the
134    /// answer (e.g. Claude's `thinking_delta`).
135    Thinking { run_id: String, delta: String },
136    /// A tool call started — render a state-ful card keyed by id.
137    /// `input` is the call's arguments when delivered inline (omitted
138    /// from the wire when absent, e.g. Claude streams them separately).
139    ToolStart {
140        run_id: String,
141        tool_call_id: String,
142        name: String,
143        #[serde(skip_serializing_if = "Option::is_none")]
144        input: Option<String>,
145        tool_kind: ToolKind,
146    },
147    /// A tool call finished (matched to its start by id). `output` is the
148    /// tool's result when the harness reports it inline (omitted when absent).
149    ToolEnd {
150        run_id: String,
151        tool_call_id: String,
152        ok: bool,
153        #[serde(skip_serializing_if = "Option::is_none")]
154        output: Option<String>,
155    },
156    /// One or more proposed edits. The app prepares + previews them.
157    SuggestedEdits {
158        run_id: String,
159        edits: Vec<SuggestedEdit>,
160    },
161    /// A human-readable status line (tool call, file touch, edit
162    /// count). Replaces the message's transient activity text.
163    Activity { run_id: String, message: String },
164    /// Token accounting for the run, emitted near its end (from the
165    /// CLI's `result` / `turn.completed`). Neutral tokens only —
166    /// harness-specific costs/credits (bob's coins) are NOT here; a
167    /// consumer that wants them reads the harness's own output. Any
168    /// field may be absent when the CLI doesn't break usage down.
169    Usage {
170        run_id: String,
171        #[serde(skip_serializing_if = "Option::is_none")]
172        input_tokens: Option<u64>,
173        #[serde(skip_serializing_if = "Option::is_none")]
174        output_tokens: Option<u64>,
175        #[serde(skip_serializing_if = "Option::is_none")]
176        total_tokens: Option<u64>,
177    },
178    /// The agent is asking the user one or more multiple-choice questions
179    /// (Claude's `AskUserQuestion`, Codex's `tool/requestUserInput`). The host
180    /// renders the options as selectable chips; the user's pick is sent back as
181    /// their **next message** on the existing chat path (which resumes the
182    /// session), so the agent continues with the answer in hand. Carrying the
183    /// questions as a neutral event keeps the harness-specific tool shape in the
184    /// adapter — the host never name-checks `AskUserQuestion` (cf. `ToolKind`).
185    AskQuestion {
186        run_id: String,
187        /// Identifies this question instance (the harness's tool-call id), so
188        /// the host can tie the answer + clear the chips for the right one.
189        request_id: String,
190        questions: Vec<Question>,
191    },
192    /// Spawn / IO / parse failure. Terminal — followed by `Exited`.
193    Error { run_id: String, message: String },
194    /// The run finished. Sent exactly once.
195    Exited {
196        run_id: String,
197        exit_code: Option<i32>,
198        cancelled: bool,
199    },
200}
201
202/// Session identity decoded from a harness's init line → `RunEvent::Session`.
203#[derive(Debug, Default, Clone, PartialEq, Eq)]
204pub struct SessionInfo {
205    pub session_id: Option<String>,
206    pub model: Option<String>,
207}
208
209/// Token accounting decoded from a harness's result line → `RunEvent::Usage`.
210#[derive(Debug, Default, Clone, PartialEq, Eq)]
211pub struct UsageInfo {
212    pub input_tokens: Option<u64>,
213    pub output_tokens: Option<u64>,
214    pub total_tokens: Option<u64>,
215}
216
217/// One multiple-choice question carried by [`RunEvent::AskQuestion`]. The
218/// neutral shape every adapter maps its harness's question tool onto. Wire-out
219/// only (Serialize), like the rest of [`RunEvent`].
220#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
221#[serde(rename_all = "camelCase")]
222pub struct Question {
223    /// Short label for the question (Claude's `header`); optional.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub header: Option<String>,
226    /// The question text shown to the user.
227    pub prompt: String,
228    pub options: Vec<QuestionOption>,
229    /// Whether more than one option may be selected.
230    pub multi_select: bool,
231    /// Whether a free-text ("Other") answer is allowed alongside the options.
232    pub allow_free_text: bool,
233}
234
235/// One selectable option of a [`Question`].
236#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
237#[serde(rename_all = "camelCase")]
238pub struct QuestionOption {
239    pub label: String,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub description: Option<String>,
242}
243
244/// What a single harness output line decoded to. A line can yield
245/// text *and* edits at once, so this is not one-event-per-line.
246#[derive(Debug, Default, Clone, PartialEq, Eq)]
247pub struct ParsedLine {
248    pub text: Option<String>,
249    /// Model reasoning chunk → `RunEvent::Thinking`. Kept separate from
250    /// `text` so the UI can render it distinctly.
251    pub thinking: Option<String>,
252    /// Session identity (id + model) → `RunEvent::Session`.
253    pub session: Option<SessionInfo>,
254    /// A tool call began → `RunEvent::ToolStart`.
255    pub tool_start: Option<ToolCallStart>,
256    /// A tool call finished → `RunEvent::ToolEnd`.
257    pub tool_end: Option<ToolCallEnd>,
258    pub edits: Vec<SuggestedEdit>,
259    /// Token accounting → `RunEvent::Usage`.
260    pub usage: Option<UsageInfo>,
261    pub activity: Option<String>,
262    /// An in-band failure the harness reported on its *stdout* (codex's
263    /// `turn.failed` / `error` lines) → `RunEvent::Error`. Terminal. Kept
264    /// distinct from `activity` so a real failure (quota mid-turn, context
265    /// overflow, model error) surfaces as an error instead of being downgraded
266    /// to transient narration — otherwise a failed turn yields no answer *and*
267    /// no error, looking like the harness silently did nothing.
268    pub error: Option<String>,
269    /// A harness asked the user a multiple-choice question (Claude's
270    /// `AskUserQuestion`) → `RunEvent::AskQuestion`. The tuple is `(request_id,
271    /// questions)`: the tool-call id the host echoes when tying the answer and
272    /// clearing the chips, plus the parsed questions. The host renders the
273    /// options as chips; the answer returns as the user's next message.
274    pub ask_question: Option<(String, Vec<Question>)>,
275}
276
277impl ParsedLine {
278    /// True when a line decoded to no actionable content. A useful
279    /// predicate for adapters + their tests; the normalize skeleton
280    /// relies instead on the natural no-op of pushing zero events.
281    pub fn is_empty(&self) -> bool {
282        self.text.is_none()
283            && self.thinking.is_none()
284            && self.session.is_none()
285            && self.tool_start.is_none()
286            && self.tool_end.is_none()
287            && self.edits.is_empty()
288            && self.usage.is_none()
289            && self.activity.is_none()
290            && self.error.is_none()
291            && self.ask_question.is_none()
292    }
293}
294
295/// Translate one raw process event into zero or more normalized
296/// [`RunEvent`]s, using `parse_line` to decode the harness's stdout
297/// wire format. Lifecycle events (Started / Exited / Error) and
298/// stderr are harness-neutral and handled here; only the stdout
299/// parsing differs per harness — so every process-backed adapter
300/// shares this skeleton and supplies just its own line parser.
301pub fn normalize_process_event(
302    event: ProcessEvent,
303    mut parse_line: impl FnMut(&str) -> ParsedLine,
304) -> Vec<RunEvent> {
305    match event {
306        ProcessEvent::Started { run_id } => vec![RunEvent::Started { run_id }],
307        ProcessEvent::Exited {
308            run_id,
309            exit_code,
310            cancelled,
311        } => vec![RunEvent::Exited {
312            run_id,
313            exit_code,
314            cancelled,
315        }],
316        ProcessEvent::Error { run_id, message } => vec![RunEvent::Error { run_id, message }],
317        ProcessEvent::Stderr { run_id, line } => {
318            // stderr is warnings/progress; surface as activity,
319            // truncated like the TS store did (240 chars).
320            let message = truncate(&line, 240);
321            if message.is_empty() {
322                vec![]
323            } else {
324                vec![RunEvent::Activity { run_id, message }]
325            }
326        }
327        ProcessEvent::Stdout { run_id, line } => run_events_from_parsed(&run_id, parse_line(&line)),
328        // `ProcessEvent` is #[non_exhaustive]; a future variant yields no
329        // events until an adapter learns to handle it.
330        _ => Vec::new(),
331    }
332}
333
334/// Expand a decoded [`ParsedLine`] into its [`RunEvent`]s for `run_id`, in a
335/// stable order: session (the run's init) → text → thinking → tool
336/// start/end → edits → usage (end of turn) → activity → error (a terminal
337/// in-band failure, emitted last so any text/usage on the same line lands
338/// before it).
339///
340/// Used by [`normalize_process_event`] and by adapters that wrap the line
341/// parser in their own per-run state (e.g. codex's preamble-vs-answer state
342/// machine, which decides *where* a message goes but still relies on this
343/// for everything else) — so the `ParsedLine` → `RunEvent` mapping lives in
344/// exactly one place.
345///
346/// Public so an **out-of-tree** harness can build a stateful parser the same
347/// way: decide your own routing per line, then call this to expand a
348/// `ParsedLine` into events with the canonical ordering — instead of
349/// hand-rolling (and drifting from) the mapping. See `examples/custom_harness.rs`.
350pub fn run_events_from_parsed(run_id: &str, parsed: ParsedLine) -> Vec<RunEvent> {
351    let mut out = Vec::new();
352    if let Some(session) = parsed.session {
353        out.push(RunEvent::Session {
354            run_id: run_id.to_owned(),
355            session_id: session.session_id,
356            model: session.model,
357        });
358    }
359    if let Some(text) = parsed.text {
360        out.push(RunEvent::Text {
361            run_id: run_id.to_owned(),
362            delta: text,
363        });
364    }
365    if let Some(thinking) = parsed.thinking {
366        out.push(RunEvent::Thinking {
367            run_id: run_id.to_owned(),
368            delta: thinking,
369        });
370    }
371    if let Some(start) = parsed.tool_start {
372        out.push(RunEvent::ToolStart {
373            run_id: run_id.to_owned(),
374            tool_call_id: start.tool_call_id,
375            name: start.name,
376            input: start.input,
377            tool_kind: start.tool_kind,
378        });
379    }
380    if let Some(end) = parsed.tool_end {
381        out.push(RunEvent::ToolEnd {
382            run_id: run_id.to_owned(),
383            tool_call_id: end.tool_call_id,
384            ok: end.ok,
385            output: end.output,
386        });
387    }
388    if !parsed.edits.is_empty() {
389        out.push(RunEvent::SuggestedEdits {
390            run_id: run_id.to_owned(),
391            edits: parsed.edits,
392        });
393    }
394    if let Some(usage) = parsed.usage {
395        out.push(RunEvent::Usage {
396            run_id: run_id.to_owned(),
397            input_tokens: usage.input_tokens,
398            output_tokens: usage.output_tokens,
399            total_tokens: usage.total_tokens,
400        });
401    }
402    if let Some(activity) = parsed.activity {
403        out.push(RunEvent::Activity {
404            run_id: run_id.to_owned(),
405            message: activity,
406        });
407    }
408    // A harness reported an in-band failure on its stdout. This is the one
409    // place a parsed line can become `RunEvent::Error` (the only other source
410    // is a process-level `ProcessEvent::Error`), so an in-band failure from any
411    // harness — not just a spawn/IO failure — reaches the consumer.
412    if let Some(message) = parsed.error {
413        out.push(RunEvent::Error {
414            run_id: run_id.to_owned(),
415            message,
416        });
417    }
418    if let Some((request_id, questions)) = parsed.ask_question {
419        out.push(RunEvent::AskQuestion {
420            run_id: run_id.to_owned(),
421            request_id,
422            questions,
423        });
424    }
425    out
426}
427
428/// Take the first `max_chars` characters (not bytes) of `s`. Bounds the
429/// stderr activity line without splitting a multi-byte char.
430fn truncate(s: &str, max_chars: usize) -> String {
431    s.chars().take(max_chars).collect()
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    /// A line parser that yields nothing — exercises the neutral
439    /// skeleton without any harness-specific decoding.
440    fn empty_parser(_: &str) -> ParsedLine {
441        ParsedLine::default()
442    }
443
444    #[test]
445    fn normalize_passes_through_lifecycle_events() {
446        assert!(matches!(
447            normalize_process_event(ProcessEvent::Started { run_id: "r".into() }, empty_parser)
448                .as_slice(),
449            [RunEvent::Started { .. }]
450        ));
451        assert!(matches!(
452            normalize_process_event(
453                ProcessEvent::Exited {
454                    run_id: "r".into(),
455                    exit_code: Some(0),
456                    cancelled: false
457                },
458                empty_parser
459            )
460            .as_slice(),
461            [RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }]
462        ));
463    }
464
465    #[test]
466    fn stderr_becomes_truncated_activity() {
467        let long = "x".repeat(500);
468        let events = normalize_process_event(
469            ProcessEvent::Stderr {
470                run_id: "r1".into(),
471                line: long,
472            },
473            empty_parser,
474        );
475        match events.as_slice() {
476            [RunEvent::Activity { run_id, message }] => {
477                assert_eq!(run_id, "r1");
478                assert_eq!(message.chars().count(), 240);
479            }
480            other => panic!("expected one Activity, got {other:?}"),
481        }
482        // Empty stderr line → no event.
483        assert!(normalize_process_event(
484            ProcessEvent::Stderr {
485                run_id: "r1".into(),
486                line: String::new(),
487            },
488            empty_parser,
489        )
490        .is_empty());
491    }
492
493    #[test]
494    fn thinking_normalizes_and_serializes() {
495        let events = normalize_process_event(
496            ProcessEvent::Stdout {
497                run_id: "r1".to_owned(),
498                line: "ignored".to_owned(),
499            },
500            |_| ParsedLine {
501                thinking: Some("pondering".to_owned()),
502                ..ParsedLine::default()
503            },
504        );
505        assert!(matches!(
506            events.as_slice(),
507            [RunEvent::Thinking { run_id, delta }] if run_id == "r1" && delta == "pondering"
508        ));
509        let json = serde_json::to_value(RunEvent::Thinking {
510            run_id: "r1".to_owned(),
511            delta: "d".to_owned(),
512        })
513        .unwrap();
514        assert_eq!(json["kind"], "thinking");
515        assert_eq!(json["runId"], "r1");
516        assert_eq!(json["delta"], "d");
517    }
518
519    #[test]
520    fn run_event_serializes_with_kind_and_camelcase() {
521        let json = serde_json::to_value(RunEvent::Exited {
522            run_id: "r1".to_owned(),
523            exit_code: Some(2),
524            cancelled: true,
525        })
526        .unwrap();
527        assert_eq!(json["kind"], "exited");
528        assert_eq!(json["runId"], "r1");
529        assert_eq!(json["exitCode"], 2);
530        assert_eq!(json["cancelled"], true);
531    }
532
533    #[test]
534    fn ask_question_serializes_with_camelcase_and_skips_empty_description() {
535        let json = serde_json::to_value(RunEvent::AskQuestion {
536            run_id: "r1".to_owned(),
537            request_id: "q-7".to_owned(),
538            questions: vec![Question {
539                header: Some("Scope".to_owned()),
540                prompt: "Which files?".to_owned(),
541                options: vec![
542                    QuestionOption { label: "All".to_owned(), description: None },
543                    QuestionOption {
544                        label: "Changed only".to_owned(),
545                        description: Some("Just the diff".to_owned()),
546                    },
547                ],
548                multi_select: true,
549                allow_free_text: false,
550            }],
551        })
552        .unwrap();
553        assert_eq!(json["kind"], "askQuestion");
554        assert_eq!(json["runId"], "r1");
555        assert_eq!(json["requestId"], "q-7");
556        let q = &json["questions"][0];
557        assert_eq!(q["header"], "Scope");
558        assert_eq!(q["prompt"], "Which files?");
559        assert_eq!(q["multiSelect"], true);
560        assert_eq!(q["allowFreeText"], false);
561        assert_eq!(q["options"][0]["label"], "All");
562        // A `None` description is omitted from the wire (skip_serializing_if).
563        assert!(q["options"][0].get("description").is_none());
564        assert_eq!(q["options"][1]["description"], "Just the diff");
565    }
566
567    #[test]
568    fn session_normalizes_and_serializes() {
569        let events = normalize_process_event(
570            ProcessEvent::Stdout {
571                run_id: "r1".to_owned(),
572                line: "ignored".to_owned(),
573            },
574            |_| ParsedLine {
575                session: Some(SessionInfo {
576                    session_id: Some("sess-1".to_owned()),
577                    model: Some("opus".to_owned()),
578                }),
579                ..ParsedLine::default()
580            },
581        );
582        assert!(matches!(
583            events.as_slice(),
584            [RunEvent::Session { run_id, session_id, model }]
585                if run_id == "r1"
586                    && session_id.as_deref() == Some("sess-1")
587                    && model.as_deref() == Some("opus")
588        ));
589        let json = serde_json::to_value(RunEvent::Session {
590            run_id: "r1".to_owned(),
591            session_id: Some("sess-1".to_owned()),
592            model: None,
593        })
594        .unwrap();
595        assert_eq!(json["kind"], "session");
596        assert_eq!(json["sessionId"], "sess-1");
597        // model omitted from the wire when None (backward-compatible).
598        assert!(json.get("model").is_none());
599    }
600
601    #[test]
602    fn usage_normalizes_and_serializes() {
603        let events = normalize_process_event(
604            ProcessEvent::Stdout {
605                run_id: "r1".to_owned(),
606                line: "ignored".to_owned(),
607            },
608            |_| ParsedLine {
609                usage: Some(UsageInfo {
610                    input_tokens: Some(10),
611                    output_tokens: Some(20),
612                    total_tokens: Some(30),
613                }),
614                ..ParsedLine::default()
615            },
616        );
617        assert!(matches!(
618            events.as_slice(),
619            [RunEvent::Usage { run_id, input_tokens: Some(10), output_tokens: Some(20), total_tokens: Some(30) }]
620                if run_id == "r1"
621        ));
622        let json = serde_json::to_value(RunEvent::Usage {
623            run_id: "r1".to_owned(),
624            input_tokens: Some(10),
625            output_tokens: None,
626            total_tokens: Some(30),
627        })
628        .unwrap();
629        assert_eq!(json["kind"], "usage");
630        assert_eq!(json["inputTokens"], 10);
631        assert_eq!(json["totalTokens"], 30);
632        assert!(json.get("outputTokens").is_none()); // omitted when None
633    }
634
635    #[test]
636    fn tool_io_is_carried_and_omitted_when_absent() {
637        // input on ToolStart, output on ToolEnd — distinct events, distinct moments.
638        let start = normalize_process_event(
639            ProcessEvent::Stdout {
640                run_id: "r1".to_owned(),
641                line: "ignored".to_owned(),
642            },
643            |_| ParsedLine {
644                tool_start: Some(ToolCallStart {
645                    tool_call_id: "t1".to_owned(),
646                    name: "ls".to_owned(),
647                    input: Some("{\"dir\":\"/x\"}".to_owned()),
648                    tool_kind: ToolKind::Other,
649                }),
650                ..ParsedLine::default()
651            },
652        );
653        assert!(matches!(
654            start.as_slice(),
655            [RunEvent::ToolStart { input: Some(i), .. }] if i == "{\"dir\":\"/x\"}"
656        ));
657        // A ToolStart with no input omits the field on the wire (byte-identical
658        // to the pre-enrichment shape).
659        let json = serde_json::to_value(RunEvent::ToolStart {
660            run_id: "r1".to_owned(),
661            tool_call_id: "t1".to_owned(),
662            name: "ls".to_owned(),
663            input: None,
664            tool_kind: ToolKind::Execute,
665        })
666        .unwrap();
667        assert_eq!(json["kind"], "toolStart");
668        // The neutral class rides alongside as `toolKind` — distinct wire key
669        // from the `kind` event discriminator (no collision).
670        assert_eq!(json["toolKind"], "execute");
671        assert_eq!(json["toolCallId"], "t1");
672        assert!(json.get("input").is_none());
673
674        let json = serde_json::to_value(RunEvent::ToolEnd {
675            run_id: "r1".to_owned(),
676            tool_call_id: "t1".to_owned(),
677            ok: true,
678            output: Some("done".to_owned()),
679        })
680        .unwrap();
681        assert_eq!(json["kind"], "toolEnd");
682        assert_eq!(json["output"], "done");
683    }
684
685    #[test]
686    fn parsed_error_normalizes_to_run_event_error() {
687        // An in-band failure decoded onto a ParsedLine surfaces as
688        // RunEvent::Error — the path codex's `turn.failed` / `error` lines now
689        // take, so a failed turn no longer yields neither answer nor error.
690        let events = normalize_process_event(
691            ProcessEvent::Stdout {
692                run_id: "r1".to_owned(),
693                line: "ignored".to_owned(),
694            },
695            |_| ParsedLine {
696                error: Some("rate limited".to_owned()),
697                ..ParsedLine::default()
698            },
699        );
700        assert!(matches!(
701            events.as_slice(),
702            [RunEvent::Error { run_id, message }] if run_id == "r1" && message == "rate limited"
703        ));
704        // An error-only line is not empty (is_empty stays honest).
705        assert!(!ParsedLine {
706            error: Some("x".to_owned()),
707            ..ParsedLine::default()
708        }
709        .is_empty());
710    }
711
712    #[test]
713    fn suggested_edits_event_serializes_camelcase() {
714        let json = serde_json::to_value(RunEvent::SuggestedEdits {
715            run_id: "r1".to_owned(),
716            edits: vec![SuggestedEdit {
717                file_path: "a.md".to_owned(),
718                range: ByteRange { start: 1, end: 2 },
719                replacement: "x".to_owned(),
720                title: None,
721            }],
722        })
723        .unwrap();
724        assert_eq!(json["kind"], "suggestedEdits");
725        assert_eq!(json["edits"][0]["filePath"], "a.md");
726        assert_eq!(json["edits"][0]["range"]["start"], 1);
727        // title omitted when None
728        assert!(json["edits"][0].get("title").is_none());
729    }
730}