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    /// Spawn / IO / parse failure. Terminal — followed by `Exited`.
179    Error { run_id: String, message: String },
180    /// The run finished. Sent exactly once.
181    Exited {
182        run_id: String,
183        exit_code: Option<i32>,
184        cancelled: bool,
185    },
186}
187
188/// Session identity decoded from a harness's init line → `RunEvent::Session`.
189#[derive(Debug, Default, Clone, PartialEq, Eq)]
190pub struct SessionInfo {
191    pub session_id: Option<String>,
192    pub model: Option<String>,
193}
194
195/// Token accounting decoded from a harness's result line → `RunEvent::Usage`.
196#[derive(Debug, Default, Clone, PartialEq, Eq)]
197pub struct UsageInfo {
198    pub input_tokens: Option<u64>,
199    pub output_tokens: Option<u64>,
200    pub total_tokens: Option<u64>,
201}
202
203/// What a single harness output line decoded to. A line can yield
204/// text *and* edits at once, so this is not one-event-per-line.
205#[derive(Debug, Default, Clone, PartialEq, Eq)]
206pub struct ParsedLine {
207    pub text: Option<String>,
208    /// Model reasoning chunk → `RunEvent::Thinking`. Kept separate from
209    /// `text` so the UI can render it distinctly.
210    pub thinking: Option<String>,
211    /// Session identity (id + model) → `RunEvent::Session`.
212    pub session: Option<SessionInfo>,
213    /// A tool call began → `RunEvent::ToolStart`.
214    pub tool_start: Option<ToolCallStart>,
215    /// A tool call finished → `RunEvent::ToolEnd`.
216    pub tool_end: Option<ToolCallEnd>,
217    pub edits: Vec<SuggestedEdit>,
218    /// Token accounting → `RunEvent::Usage`.
219    pub usage: Option<UsageInfo>,
220    pub activity: Option<String>,
221}
222
223impl ParsedLine {
224    /// True when a line decoded to no actionable content. A useful
225    /// predicate for adapters + their tests; the normalize skeleton
226    /// relies instead on the natural no-op of pushing zero events.
227    pub fn is_empty(&self) -> bool {
228        self.text.is_none()
229            && self.thinking.is_none()
230            && self.session.is_none()
231            && self.tool_start.is_none()
232            && self.tool_end.is_none()
233            && self.edits.is_empty()
234            && self.usage.is_none()
235            && self.activity.is_none()
236    }
237}
238
239/// Translate one raw process event into zero or more normalized
240/// [`RunEvent`]s, using `parse_line` to decode the harness's stdout
241/// wire format. Lifecycle events (Started / Exited / Error) and
242/// stderr are harness-neutral and handled here; only the stdout
243/// parsing differs per harness — so every process-backed adapter
244/// shares this skeleton and supplies just its own line parser.
245pub fn normalize_process_event(
246    event: ProcessEvent,
247    mut parse_line: impl FnMut(&str) -> ParsedLine,
248) -> Vec<RunEvent> {
249    match event {
250        ProcessEvent::Started { run_id } => vec![RunEvent::Started { run_id }],
251        ProcessEvent::Exited {
252            run_id,
253            exit_code,
254            cancelled,
255        } => vec![RunEvent::Exited {
256            run_id,
257            exit_code,
258            cancelled,
259        }],
260        ProcessEvent::Error { run_id, message } => vec![RunEvent::Error { run_id, message }],
261        ProcessEvent::Stderr { run_id, line } => {
262            // stderr is warnings/progress; surface as activity,
263            // truncated like the TS store did (240 chars).
264            let message = truncate(&line, 240);
265            if message.is_empty() {
266                vec![]
267            } else {
268                vec![RunEvent::Activity { run_id, message }]
269            }
270        }
271        ProcessEvent::Stdout { run_id, line } => run_events_from_parsed(&run_id, parse_line(&line)),
272        // `ProcessEvent` is #[non_exhaustive]; a future variant yields no
273        // events until an adapter learns to handle it.
274        _ => Vec::new(),
275    }
276}
277
278/// Expand a decoded [`ParsedLine`] into its [`RunEvent`]s for `run_id`, in a
279/// stable order: session (the run's init) → text → thinking → tool
280/// start/end → edits → usage (end of turn) → activity.
281///
282/// Used by [`normalize_process_event`] and by adapters that wrap the line
283/// parser in their own per-run state (e.g. codex's preamble-vs-answer state
284/// machine, which decides *where* a message goes but still relies on this
285/// for everything else) — so the `ParsedLine` → `RunEvent` mapping lives in
286/// exactly one place.
287///
288/// Public so an **out-of-tree** harness can build a stateful parser the same
289/// way: decide your own routing per line, then call this to expand a
290/// `ParsedLine` into events with the canonical ordering — instead of
291/// hand-rolling (and drifting from) the mapping. See `examples/custom_harness.rs`.
292pub fn run_events_from_parsed(run_id: &str, parsed: ParsedLine) -> Vec<RunEvent> {
293    let mut out = Vec::new();
294    if let Some(session) = parsed.session {
295        out.push(RunEvent::Session {
296            run_id: run_id.to_owned(),
297            session_id: session.session_id,
298            model: session.model,
299        });
300    }
301    if let Some(text) = parsed.text {
302        out.push(RunEvent::Text {
303            run_id: run_id.to_owned(),
304            delta: text,
305        });
306    }
307    if let Some(thinking) = parsed.thinking {
308        out.push(RunEvent::Thinking {
309            run_id: run_id.to_owned(),
310            delta: thinking,
311        });
312    }
313    if let Some(start) = parsed.tool_start {
314        out.push(RunEvent::ToolStart {
315            run_id: run_id.to_owned(),
316            tool_call_id: start.tool_call_id,
317            name: start.name,
318            input: start.input,
319            tool_kind: start.tool_kind,
320        });
321    }
322    if let Some(end) = parsed.tool_end {
323        out.push(RunEvent::ToolEnd {
324            run_id: run_id.to_owned(),
325            tool_call_id: end.tool_call_id,
326            ok: end.ok,
327            output: end.output,
328        });
329    }
330    if !parsed.edits.is_empty() {
331        out.push(RunEvent::SuggestedEdits {
332            run_id: run_id.to_owned(),
333            edits: parsed.edits,
334        });
335    }
336    if let Some(usage) = parsed.usage {
337        out.push(RunEvent::Usage {
338            run_id: run_id.to_owned(),
339            input_tokens: usage.input_tokens,
340            output_tokens: usage.output_tokens,
341            total_tokens: usage.total_tokens,
342        });
343    }
344    if let Some(activity) = parsed.activity {
345        out.push(RunEvent::Activity {
346            run_id: run_id.to_owned(),
347            message: activity,
348        });
349    }
350    out
351}
352
353/// Take the first `max_chars` characters (not bytes) of `s`. Bounds the
354/// stderr activity line without splitting a multi-byte char.
355fn truncate(s: &str, max_chars: usize) -> String {
356    s.chars().take(max_chars).collect()
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    /// A line parser that yields nothing — exercises the neutral
364    /// skeleton without any harness-specific decoding.
365    fn empty_parser(_: &str) -> ParsedLine {
366        ParsedLine::default()
367    }
368
369    #[test]
370    fn normalize_passes_through_lifecycle_events() {
371        assert!(matches!(
372            normalize_process_event(ProcessEvent::Started { run_id: "r".into() }, empty_parser)
373                .as_slice(),
374            [RunEvent::Started { .. }]
375        ));
376        assert!(matches!(
377            normalize_process_event(
378                ProcessEvent::Exited {
379                    run_id: "r".into(),
380                    exit_code: Some(0),
381                    cancelled: false
382                },
383                empty_parser
384            )
385            .as_slice(),
386            [RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }]
387        ));
388    }
389
390    #[test]
391    fn stderr_becomes_truncated_activity() {
392        let long = "x".repeat(500);
393        let events = normalize_process_event(
394            ProcessEvent::Stderr {
395                run_id: "r1".into(),
396                line: long,
397            },
398            empty_parser,
399        );
400        match events.as_slice() {
401            [RunEvent::Activity { run_id, message }] => {
402                assert_eq!(run_id, "r1");
403                assert_eq!(message.chars().count(), 240);
404            }
405            other => panic!("expected one Activity, got {other:?}"),
406        }
407        // Empty stderr line → no event.
408        assert!(normalize_process_event(
409            ProcessEvent::Stderr {
410                run_id: "r1".into(),
411                line: String::new(),
412            },
413            empty_parser,
414        )
415        .is_empty());
416    }
417
418    #[test]
419    fn thinking_normalizes_and_serializes() {
420        let events = normalize_process_event(
421            ProcessEvent::Stdout {
422                run_id: "r1".to_owned(),
423                line: "ignored".to_owned(),
424            },
425            |_| ParsedLine {
426                thinking: Some("pondering".to_owned()),
427                ..ParsedLine::default()
428            },
429        );
430        assert!(matches!(
431            events.as_slice(),
432            [RunEvent::Thinking { run_id, delta }] if run_id == "r1" && delta == "pondering"
433        ));
434        let json = serde_json::to_value(RunEvent::Thinking {
435            run_id: "r1".to_owned(),
436            delta: "d".to_owned(),
437        })
438        .unwrap();
439        assert_eq!(json["kind"], "thinking");
440        assert_eq!(json["runId"], "r1");
441        assert_eq!(json["delta"], "d");
442    }
443
444    #[test]
445    fn run_event_serializes_with_kind_and_camelcase() {
446        let json = serde_json::to_value(RunEvent::Exited {
447            run_id: "r1".to_owned(),
448            exit_code: Some(2),
449            cancelled: true,
450        })
451        .unwrap();
452        assert_eq!(json["kind"], "exited");
453        assert_eq!(json["runId"], "r1");
454        assert_eq!(json["exitCode"], 2);
455        assert_eq!(json["cancelled"], true);
456    }
457
458    #[test]
459    fn session_normalizes_and_serializes() {
460        let events = normalize_process_event(
461            ProcessEvent::Stdout {
462                run_id: "r1".to_owned(),
463                line: "ignored".to_owned(),
464            },
465            |_| ParsedLine {
466                session: Some(SessionInfo {
467                    session_id: Some("sess-1".to_owned()),
468                    model: Some("opus".to_owned()),
469                }),
470                ..ParsedLine::default()
471            },
472        );
473        assert!(matches!(
474            events.as_slice(),
475            [RunEvent::Session { run_id, session_id, model }]
476                if run_id == "r1"
477                    && session_id.as_deref() == Some("sess-1")
478                    && model.as_deref() == Some("opus")
479        ));
480        let json = serde_json::to_value(RunEvent::Session {
481            run_id: "r1".to_owned(),
482            session_id: Some("sess-1".to_owned()),
483            model: None,
484        })
485        .unwrap();
486        assert_eq!(json["kind"], "session");
487        assert_eq!(json["sessionId"], "sess-1");
488        // model omitted from the wire when None (backward-compatible).
489        assert!(json.get("model").is_none());
490    }
491
492    #[test]
493    fn usage_normalizes_and_serializes() {
494        let events = normalize_process_event(
495            ProcessEvent::Stdout {
496                run_id: "r1".to_owned(),
497                line: "ignored".to_owned(),
498            },
499            |_| ParsedLine {
500                usage: Some(UsageInfo {
501                    input_tokens: Some(10),
502                    output_tokens: Some(20),
503                    total_tokens: Some(30),
504                }),
505                ..ParsedLine::default()
506            },
507        );
508        assert!(matches!(
509            events.as_slice(),
510            [RunEvent::Usage { run_id, input_tokens: Some(10), output_tokens: Some(20), total_tokens: Some(30) }]
511                if run_id == "r1"
512        ));
513        let json = serde_json::to_value(RunEvent::Usage {
514            run_id: "r1".to_owned(),
515            input_tokens: Some(10),
516            output_tokens: None,
517            total_tokens: Some(30),
518        })
519        .unwrap();
520        assert_eq!(json["kind"], "usage");
521        assert_eq!(json["inputTokens"], 10);
522        assert_eq!(json["totalTokens"], 30);
523        assert!(json.get("outputTokens").is_none()); // omitted when None
524    }
525
526    #[test]
527    fn tool_io_is_carried_and_omitted_when_absent() {
528        // input on ToolStart, output on ToolEnd — distinct events, distinct moments.
529        let start = normalize_process_event(
530            ProcessEvent::Stdout {
531                run_id: "r1".to_owned(),
532                line: "ignored".to_owned(),
533            },
534            |_| ParsedLine {
535                tool_start: Some(ToolCallStart {
536                    tool_call_id: "t1".to_owned(),
537                    name: "ls".to_owned(),
538                    input: Some("{\"dir\":\"/x\"}".to_owned()),
539                    tool_kind: ToolKind::Other,
540                }),
541                ..ParsedLine::default()
542            },
543        );
544        assert!(matches!(
545            start.as_slice(),
546            [RunEvent::ToolStart { input: Some(i), .. }] if i == "{\"dir\":\"/x\"}"
547        ));
548        // A ToolStart with no input omits the field on the wire (byte-identical
549        // to the pre-enrichment shape).
550        let json = serde_json::to_value(RunEvent::ToolStart {
551            run_id: "r1".to_owned(),
552            tool_call_id: "t1".to_owned(),
553            name: "ls".to_owned(),
554            input: None,
555            tool_kind: ToolKind::Execute,
556        })
557        .unwrap();
558        assert_eq!(json["kind"], "toolStart");
559        // The neutral class rides alongside as `toolKind` — distinct wire key
560        // from the `kind` event discriminator (no collision).
561        assert_eq!(json["toolKind"], "execute");
562        assert_eq!(json["toolCallId"], "t1");
563        assert!(json.get("input").is_none());
564
565        let json = serde_json::to_value(RunEvent::ToolEnd {
566            run_id: "r1".to_owned(),
567            tool_call_id: "t1".to_owned(),
568            ok: true,
569            output: Some("done".to_owned()),
570        })
571        .unwrap();
572        assert_eq!(json["kind"], "toolEnd");
573        assert_eq!(json["output"], "done");
574    }
575
576    #[test]
577    fn suggested_edits_event_serializes_camelcase() {
578        let json = serde_json::to_value(RunEvent::SuggestedEdits {
579            run_id: "r1".to_owned(),
580            edits: vec![SuggestedEdit {
581                file_path: "a.md".to_owned(),
582                range: ByteRange { start: 1, end: 2 },
583                replacement: "x".to_owned(),
584                title: None,
585            }],
586        })
587        .unwrap();
588        assert_eq!(json["kind"], "suggestedEdits");
589        assert_eq!(json["edits"][0]["filePath"], "a.md");
590        assert_eq!(json["edits"][0]["range"]["start"], 1);
591        // title omitted when None
592        assert!(json["edits"][0].get("title").is_none());
593    }
594}