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