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::Event;
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///
53/// The values mirror ACP's tool-call `kind`
54/// (`read`/`edit`/`delete`/`move`/`search`/`execute`/`fetch`/`other`), so a
55/// [`RunEvent::ToolStart`] maps onto an ACP `tool_call` without a translation
56/// table. The one divergence: our `Write` (create/overwrite a whole file) has
57/// no ACP counterpart — ACP folds whole-file writes into `edit` — so an
58/// ACP bridge maps `Write` → `edit`.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "camelCase")]
61#[non_exhaustive]
62pub enum ToolKind {
63    /// Read or inspect a file's contents. (ACP `read`.)
64    Read,
65    /// Create or overwrite a whole file. (No ACP kind — bridges to `edit`.)
66    Write,
67    /// Modify part of an existing file. (ACP `edit`.)
68    Edit,
69    /// Delete a file. (ACP `delete`.)
70    Delete,
71    /// Move or rename a file. (ACP `move`.)
72    Move,
73    /// Search or list files / the web. (ACP `search`.)
74    Search,
75    /// Run a shell command or external process. (ACP `execute`.)
76    Execute,
77    /// Fetch a URL / remote resource. (ACP `fetch`.)
78    Fetch,
79    /// Anything else (MCP calls, task spawns, completion signals, …). (ACP `other`.)
80    Other,
81}
82
83/// A file/path a tool call touches — the neutral mirror of ACP's
84/// `ToolCallLocation`. Lets the app show the call's subject (and distinguish,
85/// say, listing a directory from reading a file) and offer follow-along
86/// navigation. Populated by the ACP adapter (passthrough) and openai-compatible's
87/// own tools; empty for the CLI adapters that don't report paths structurally.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub struct ToolLocation {
91    pub path: String,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub line: Option<u32>,
94}
95
96/// A tool call beginning — its id + name, so the UI can render a
97/// state-ful card (running → done/✗) keyed by `tool_call_id`. `input`
98/// carries the call's arguments when the harness delivers them inline
99/// at the start (bob's `parameters`, codex's `command`); it is `None`
100/// when the harness streams them incrementally (Claude's
101/// `input_json_delta`), so the card stays correct either way. `tool_kind`
102/// is the neutral behaviour class (see [`ToolKind`]).
103#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104#[serde(rename_all = "camelCase")]
105pub struct ToolCallStart {
106    pub tool_call_id: String,
107    pub name: String,
108    pub input: Option<String>,
109    pub tool_kind: ToolKind,
110}
111
112/// A tool call finishing — matched to its start by `tool_call_id`.
113/// `output` carries the tool's result when the harness reports it
114/// inline at completion (bob's `tool_result.output`, codex's
115/// `aggregated_output`, Claude's `tool_result.content`).
116#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub struct ToolCallEnd {
119    pub tool_call_id: String,
120    pub ok: bool,
121    pub output: Option<String>,
122}
123
124/// The normalized event stream. `#[serde(tag = "kind")]` +
125/// camelCase mirrors the existing `Event` wire contract the TS
126/// store already reads (`event.kind`, `event.runId`, …), so the
127/// front-end consumes one shape regardless of which harness produced it.
128// Not `Eq`: `Usage.cost_usd` is an `f64` (only `PartialEq`). `==`/`assert_eq!`
129// still work; `RunEvent` just can't be a `HashSet`/`HashMap` key.
130#[derive(Debug, Clone, PartialEq, Serialize)]
131// `rename_all` camelCases the variant tags ("suggestedEdits"); serde
132// does NOT cascade that to struct-variant fields, so `rename_all_fields`
133// is required to get `runId` / `exitCode` on the wire rather than the
134// snake_case Rust idents.
135#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
136// New event kinds (a richer Usage, a new lifecycle signal, …) can be added
137// without breaking consumers — they must carry a `_` arm. Adding `Session` /
138// `Usage` earlier was a breaking change precisely because this was missing.
139#[non_exhaustive]
140pub enum RunEvent {
141    /// First event, before any output. UI shows "thinking…". Fired the
142    /// instant the process spawns — *before* the CLI reports its
143    /// session/model, which arrive separately as [`RunEvent::Session`].
144    Started { run_id: String },
145    /// The agent session is established — its id and the model in use.
146    /// Distinct from `Started` because it arrives a beat later, in the
147    /// CLI's first output line (bob's `init`, Claude's `system/init`,
148    /// codex's `thread.started`); keeping `Started` instant matters for
149    /// the "thinking…" feedback. Either field may be absent when the CLI
150    /// doesn't report it (e.g. codex gives a thread id but no model).
151    /// Constructible out-of-tree: any `Harness` (in-tree, or a third-party
152    /// crate like `openai-compatible`) mints this directly, so it is *not*
153    /// variant-`#[non_exhaustive]` — sealing it would break the open-producer
154    /// contract (see the note on [`RunEvent::Exited`]).
155    Session {
156        run_id: String,
157        #[serde(skip_serializing_if = "Option::is_none")]
158        session_id: Option<String>,
159        #[serde(skip_serializing_if = "Option::is_none")]
160        model: Option<String>,
161    },
162    /// A chunk of assistant text. Appended to the active message.
163    Text { run_id: String, delta: String },
164    /// A chunk of model reasoning ("thinking"), rendered distinctly from
165    /// `Text` so the UI can show reasoning without mixing it into the
166    /// answer (e.g. Claude's `thinking_delta`).
167    Thinking { run_id: String, delta: String },
168    /// A tool call started — render a state-ful card keyed by id. Mirrors ACP's
169    /// `ToolCall`: `title` + `kind` + `locations` (files it touches) + the raw
170    /// arguments (`raw_input`, omitted when streamed separately, e.g. Claude).
171    ToolStart {
172        run_id: String,
173        tool_call_id: String,
174        title: String,
175        // ACP calls this `kind`; our wire reserves `kind` for the event tag, so
176        // it stays `tool_kind` (→ `toolKind` on the wire).
177        tool_kind: ToolKind,
178        #[serde(default, skip_serializing_if = "Vec::is_empty")]
179        locations: Vec<ToolLocation>,
180        #[serde(skip_serializing_if = "Option::is_none")]
181        raw_input: Option<String>,
182    },
183    /// A tool call finished (matched to its start by id). Mirrors ACP's tool
184    /// result: `content` (human-readable, flattened to text) + `raw_output`
185    /// (structured JSON) + the `locations` it touched. `ok` reduces ACP's
186    /// terminal `Completed`/`Failed` status to a flag.
187    ToolEnd {
188        run_id: String,
189        tool_call_id: String,
190        ok: bool,
191        #[serde(skip_serializing_if = "Option::is_none")]
192        content: Option<String>,
193        #[serde(default, skip_serializing_if = "Option::is_none")]
194        raw_output: Option<String>,
195        #[serde(default, skip_serializing_if = "Vec::is_empty")]
196        locations: Vec<ToolLocation>,
197    },
198    /// One or more proposed edits. The app prepares + previews them.
199    SuggestedEdits {
200        run_id: String,
201        edits: Vec<SuggestedEdit>,
202    },
203    /// A human-readable status line (tool call, file touch, edit
204    /// count). Replaces the message's transient activity text.
205    Activity { run_id: String, message: String },
206    /// Token accounting for the run, emitted near its end (from the
207    /// CLI's `result` / `turn.completed`). Neutral tokens only —
208    /// harness-specific costs/credits (bob's coins) are NOT here; a
209    /// consumer that wants them reads the harness's own output. Any
210    /// field may be absent when the CLI doesn't break usage down.
211    ///
212    /// `cache_read_tokens` / `cache_write_tokens` are the prompt-cache
213    /// counters reported *separately* from `input_tokens` (Claude's
214    /// `cache_read_input_tokens` / `cache_creation_input_tokens`) — not folded
215    /// into `input_tokens`, and omitted from the wire when the CLI doesn't
216    /// report caching.
217    Usage {
218        run_id: String,
219        #[serde(skip_serializing_if = "Option::is_none")]
220        input_tokens: Option<u64>,
221        #[serde(skip_serializing_if = "Option::is_none")]
222        output_tokens: Option<u64>,
223        #[serde(skip_serializing_if = "Option::is_none")]
224        total_tokens: Option<u64>,
225        /// Prompt-cache tokens served from cache this run (~0.1x input cost).
226        #[serde(skip_serializing_if = "Option::is_none")]
227        cache_read_tokens: Option<u64>,
228        /// Prompt-cache tokens written to cache this run (~1.25x input cost).
229        #[serde(skip_serializing_if = "Option::is_none")]
230        cache_write_tokens: Option<u64>,
231        /// Estimated cost in USD for this run, when the adapter knows per-token
232        /// rates (`openai-compatible` via `with_model_cost`); `None` otherwise.
233        #[serde(skip_serializing_if = "Option::is_none")]
234        cost_usd: Option<f64>,
235    },
236    /// The agent is asking the user one or more multiple-choice questions
237    /// (Claude's `AskUserQuestion`, Codex's `tool/requestUserInput`). The host
238    /// renders the options as selectable chips; the user's pick is sent back as
239    /// their **next message** on the existing chat path (which resumes the
240    /// session), so the agent continues with the answer in hand. Carrying the
241    /// questions as a neutral event keeps the harness-specific tool shape in the
242    /// adapter — the host never name-checks `AskUserQuestion` (cf. `ToolKind`).
243    AskQuestion {
244        run_id: String,
245        /// Identifies this question instance (the harness's tool-call id), so
246        /// the host can tie the answer + clear the chips for the right one.
247        request_id: String,
248        questions: Vec<Question>,
249    },
250    /// The agent's current task plan / todo list (Claude's `TodoWrite`,
251    /// Codex's plan items), replacing any prior plan for this run. The host
252    /// renders a checklist without knowing the harness's native plan tool.
253    /// The neutral plan vocabulary adapters map onto — the `acp` adapter from
254    /// ACP `plan`, and `openai-compatible` from its `todowrite` tool.
255    Plan { run_id: String, entries: Vec<PlanEntry> },
256    /// A live update to the session's display metadata — its title and/or
257    /// last-updated time — emitted mid-run when the agent (re)names the
258    /// conversation. Maps field-for-field onto ACP `session_info_update`
259    /// (`title` + `updatedAt`); lets a sessions list show a meaningful title
260    /// before the run ends. Both fields optional (a partial update).
261    SessionInfoUpdate {
262        run_id: String,
263        #[serde(skip_serializing_if = "Option::is_none")]
264        title: Option<String>,
265        /// ISO-8601 timestamp of the update, when the harness reports one
266        /// (ACP `updatedAt`). Omitted from the wire when absent.
267        #[serde(skip_serializing_if = "Option::is_none")]
268        updated_at: Option<String>,
269    },
270    /// Command / IO / parse failure. Terminal — followed by `Exited`.
271    Error { run_id: String, message: String },
272    /// The run finished. Sent exactly once. Like every `RunEvent` variant it is
273    /// constructible out-of-tree: harnesses live in their own crates (the
274    /// `Registry` is open — `openai-compatible`, plus `examples/custom_harness.rs`),
275    /// so no *produced* variant is variant-`#[non_exhaustive]` — that would
276    /// close the producer door. The enum itself stays `#[non_exhaustive]`, which
277    /// protects *consumers* (a new variant just needs a `_` arm) without
278    /// blocking construction of the existing ones.
279    Exited {
280        run_id: String,
281        exit_code: Option<i32>,
282        cancelled: bool,
283    },
284}
285
286/// Session identity decoded from a harness's init line → `RunEvent::Session`.
287#[derive(Debug, Default, Clone, PartialEq, Eq)]
288pub struct SessionInfo {
289    pub session_id: Option<String>,
290    pub model: Option<String>,
291}
292
293/// Token accounting decoded from a harness's result line → `RunEvent::Usage`.
294#[derive(Debug, Default, Clone, PartialEq, Eq)]
295pub struct UsageInfo {
296    pub input_tokens: Option<u64>,
297    pub output_tokens: Option<u64>,
298    pub total_tokens: Option<u64>,
299    /// Prompt-cache tokens read this run (Claude's `cache_read_input_tokens`).
300    pub cache_read_tokens: Option<u64>,
301    /// Prompt-cache tokens written (Claude's `cache_creation_input_tokens`).
302    pub cache_write_tokens: Option<u64>,
303}
304
305/// One multiple-choice question carried by [`RunEvent::AskQuestion`]. The
306/// neutral shape every adapter maps its harness's question tool onto. Wire-out
307/// only (Serialize), like the rest of [`RunEvent`].
308#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
309#[serde(rename_all = "camelCase")]
310pub struct Question {
311    /// Short label for the question (Claude's `header`); optional.
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub header: Option<String>,
314    /// The question text shown to the user.
315    pub prompt: String,
316    pub options: Vec<QuestionOption>,
317    /// Whether more than one option may be selected.
318    pub multi_select: bool,
319    /// Whether a free-text ("Other") answer is allowed alongside the options.
320    pub allow_free_text: bool,
321}
322
323/// One selectable option of a [`Question`].
324#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
325#[serde(rename_all = "camelCase")]
326pub struct QuestionOption {
327    pub label: String,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub description: Option<String>,
330}
331
332/// One entry in a [`RunEvent::Plan`] — a single task the agent is tracking.
333/// Wire-out only (Serialize), like the rest of [`RunEvent`].
334#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
335#[serde(rename_all = "camelCase")]
336pub struct PlanEntry {
337    /// The step text (the todo content).
338    pub content: String,
339    pub status: PlanEntryStatus,
340    /// Relative importance when the harness ranks steps; omitted otherwise.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub priority: Option<PlanEntryPriority>,
343}
344
345/// Lifecycle of a [`PlanEntry`]. `#[non_exhaustive]` — a harness may report
346/// states beyond these.
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
348#[serde(rename_all = "camelCase")]
349#[non_exhaustive]
350pub enum PlanEntryStatus {
351    Pending,
352    InProgress,
353    Completed,
354    /// The step was abandoned (OpenCode's `cancelled` todo status).
355    Cancelled,
356}
357
358/// Relative priority of a [`PlanEntry`]. `#[non_exhaustive]`.
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
360#[serde(rename_all = "camelCase")]
361#[non_exhaustive]
362pub enum PlanEntryPriority {
363    Low,
364    Medium,
365    High,
366}
367
368/// What a single harness output line decoded to. A line can yield
369/// text *and* edits at once, so this is not one-event-per-line.
370#[derive(Debug, Default, Clone, PartialEq, Eq)]
371pub struct ParsedLine {
372    pub text: Option<String>,
373    /// Model reasoning chunk → `RunEvent::Thinking`. Kept separate from
374    /// `text` so the UI can render it distinctly.
375    pub thinking: Option<String>,
376    /// Session identity (id + model) → `RunEvent::Session`.
377    pub session: Option<SessionInfo>,
378    /// A tool call began → `RunEvent::ToolStart`.
379    pub tool_start: Option<ToolCallStart>,
380    /// A tool call finished → `RunEvent::ToolEnd`.
381    pub tool_end: Option<ToolCallEnd>,
382    pub edits: Vec<SuggestedEdit>,
383    /// Token accounting → `RunEvent::Usage`.
384    pub usage: Option<UsageInfo>,
385    pub activity: Option<String>,
386    /// An in-band failure the harness reported on its *stdout* (codex's
387    /// `turn.failed` / `error` lines) → `RunEvent::Error`. Terminal. Kept
388    /// distinct from `activity` so a real failure (quota mid-turn, context
389    /// overflow, model error) surfaces as an error instead of being downgraded
390    /// to transient narration — otherwise a failed turn yields no answer *and*
391    /// no error, looking like the harness silently did nothing.
392    pub error: Option<String>,
393    /// A harness asked the user a multiple-choice question (Claude's
394    /// `AskUserQuestion`) → `RunEvent::AskQuestion`. The tuple is `(request_id,
395    /// questions)`: the tool-call id the host echoes when tying the answer and
396    /// clearing the chips, plus the parsed questions. The host renders the
397    /// options as chips; the answer returns as the user's next message.
398    pub ask_question: Option<(String, Vec<Question>)>,
399    /// The agent's current plan / todo list → `RunEvent::Plan`. No built-in
400    /// parser fills this yet; it's wired for adapters that will.
401    pub plan: Option<Vec<PlanEntry>>,
402    /// A live session-title update → `RunEvent::SessionInfoUpdate`.
403    pub title: Option<String>,
404}
405
406impl ParsedLine {
407    /// True when a line decoded to no actionable content. A useful
408    /// predicate for adapters + their tests; the normalize skeleton
409    /// relies instead on the natural no-op of pushing zero events.
410    pub fn is_empty(&self) -> bool {
411        self.text.is_none()
412            && self.thinking.is_none()
413            && self.session.is_none()
414            && self.tool_start.is_none()
415            && self.tool_end.is_none()
416            && self.edits.is_empty()
417            && self.usage.is_none()
418            && self.activity.is_none()
419            && self.error.is_none()
420            && self.ask_question.is_none()
421            && self.plan.is_none()
422            && self.title.is_none()
423    }
424}
425
426/// Translate one raw process event into zero or more normalized
427/// [`RunEvent`]s, using `parse_line` to decode the harness's stdout
428/// wire format. Lifecycle events (Started / Exited / Error) and
429/// stderr are harness-neutral and handled here; only the stdout
430/// parsing differs per harness — so every process-backed adapter
431/// shares this skeleton and supplies just its own line parser.
432pub fn normalize_process_event(
433    event: Event,
434    mut parse_line: impl FnMut(&str) -> ParsedLine,
435) -> Vec<RunEvent> {
436    match event {
437        Event::Started { run_id } => vec![RunEvent::Started { run_id }],
438        Event::Exited {
439            run_id,
440            exit_code,
441            cancelled,
442        } => vec![RunEvent::Exited {
443            run_id,
444            exit_code,
445            cancelled,
446        }],
447        Event::Error { run_id, message } => vec![RunEvent::Error { run_id, message }],
448        Event::Stderr { run_id, line } => {
449            // stderr is warnings/progress; surface as activity,
450            // truncated like the TS store did (240 chars).
451            let message = truncate(&line, 240);
452            if message.is_empty() {
453                vec![]
454            } else if cli_stream::needs_terminal(&line) {
455                // Otherwise this arrives as one more line of noise, and the run
456                // looks like it failed for no reason. Agents are spawned with
457                // pipes, so a CLI wanting a terminal needs its headless mode.
458                vec![RunEvent::Activity {
459                    run_id,
460                    message: format!("this agent wants an interactive terminal, which a run cannot give it — use its non-interactive mode. It said: {message}"),
461                }]
462            } else {
463                vec![RunEvent::Activity { run_id, message }]
464            }
465        }
466        Event::Stdout { run_id, line } => run_events_from_parsed(&run_id, parse_line(&line)),
467        // `Event` is #[non_exhaustive]; a future variant yields no
468        // events until an adapter learns to handle it.
469        _ => Vec::new(),
470    }
471}
472
473/// Expand a decoded [`ParsedLine`] into its [`RunEvent`]s for `run_id`, in a
474/// stable order: session (the run's init) → session-info/title → text →
475/// thinking → tool start/end → edits → plan → usage (end of turn) → activity
476/// → error (a terminal in-band failure, emitted last so any text/usage on the
477/// same line lands before it) → ask-question.
478///
479/// Used by [`normalize_process_event`] and by adapters that wrap the line
480/// parser in their own per-run state (e.g. codex's preamble-vs-answer state
481/// machine, which decides *where* a message goes but still relies on this
482/// for everything else) — so the `ParsedLine` → `RunEvent` mapping lives in
483/// exactly one place.
484///
485/// Public so an **out-of-tree** harness can build a stateful parser the same
486/// way: decide your own routing per line, then call this to expand a
487/// `ParsedLine` into events with the canonical ordering — instead of
488/// hand-rolling (and drifting from) the mapping. See `examples/custom_harness.rs`.
489pub fn run_events_from_parsed(run_id: &str, parsed: ParsedLine) -> Vec<RunEvent> {
490    let mut out = Vec::new();
491    if let Some(session) = parsed.session {
492        out.push(RunEvent::Session {
493            run_id: run_id.to_owned(),
494            session_id: session.session_id,
495            model: session.model,
496        });
497    }
498    if let Some(title) = parsed.title {
499        out.push(RunEvent::SessionInfoUpdate {
500            run_id: run_id.to_owned(),
501            title: Some(title),
502            // No parser supplies a timestamp yet; an adapter that has one
503            // constructs SessionInfoUpdate directly.
504            updated_at: None,
505        });
506    }
507    if let Some(text) = parsed.text {
508        out.push(RunEvent::Text {
509            run_id: run_id.to_owned(),
510            delta: text,
511        });
512    }
513    if let Some(thinking) = parsed.thinking {
514        out.push(RunEvent::Thinking {
515            run_id: run_id.to_owned(),
516            delta: thinking,
517        });
518    }
519    if let Some(start) = parsed.tool_start {
520        out.push(RunEvent::ToolStart {
521            run_id: run_id.to_owned(),
522            tool_call_id: start.tool_call_id,
523            title: start.name,
524            tool_kind: start.tool_kind,
525            // CLI adapters don't report structured paths; ACP + openai-compatible do.
526            locations: Vec::new(),
527            raw_input: start.input,
528        });
529    }
530    if let Some(end) = parsed.tool_end {
531        out.push(RunEvent::ToolEnd {
532            run_id: run_id.to_owned(),
533            tool_call_id: end.tool_call_id,
534            ok: end.ok,
535            content: end.output,
536            raw_output: None,
537            locations: Vec::new(),
538        });
539    }
540    if !parsed.edits.is_empty() {
541        out.push(RunEvent::SuggestedEdits {
542            run_id: run_id.to_owned(),
543            edits: parsed.edits,
544        });
545    }
546    if let Some(entries) = parsed.plan {
547        out.push(RunEvent::Plan {
548            run_id: run_id.to_owned(),
549            entries,
550        });
551    }
552    if let Some(usage) = parsed.usage {
553        out.push(RunEvent::Usage {
554            run_id: run_id.to_owned(),
555            input_tokens: usage.input_tokens,
556            output_tokens: usage.output_tokens,
557            total_tokens: usage.total_tokens,
558            cache_read_tokens: usage.cache_read_tokens,
559            cache_write_tokens: usage.cache_write_tokens,
560            // CLI adapters don't compute cost; openai-compatible does (with rates).
561            cost_usd: None,
562        });
563    }
564    if let Some(activity) = parsed.activity {
565        out.push(RunEvent::Activity {
566            run_id: run_id.to_owned(),
567            message: activity,
568        });
569    }
570    // A harness reported an in-band failure on its stdout. This is the one
571    // place a parsed line can become `RunEvent::Error` (the only other source
572    // is a process-level `Event::Error`), so an in-band failure from any
573    // harness — not just a spawn/IO failure — reaches the consumer.
574    if let Some(message) = parsed.error {
575        out.push(RunEvent::Error {
576            run_id: run_id.to_owned(),
577            message,
578        });
579    }
580    if let Some((request_id, questions)) = parsed.ask_question {
581        out.push(RunEvent::AskQuestion {
582            run_id: run_id.to_owned(),
583            request_id,
584            questions,
585        });
586    }
587    out
588}
589
590/// Take the first `max_chars` characters (not bytes) of `s`. Bounds the
591/// stderr activity line without splitting a multi-byte char.
592fn truncate(s: &str, max_chars: usize) -> String {
593    s.chars().take(max_chars).collect()
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    /// A line parser that yields nothing — exercises the neutral
601    /// skeleton without any harness-specific decoding.
602    fn empty_parser(_: &str) -> ParsedLine {
603        ParsedLine::default()
604    }
605
606    #[test]
607    fn normalize_passes_through_lifecycle_events() {
608        assert!(matches!(
609            normalize_process_event(Event::Started { run_id: "r".into() }, empty_parser)
610                .as_slice(),
611            [RunEvent::Started { .. }]
612        ));
613        assert!(matches!(
614            normalize_process_event(
615                Event::Exited {
616                    run_id: "r".into(),
617                    exit_code: Some(0),
618                    cancelled: false
619                },
620                empty_parser
621            )
622            .as_slice(),
623            [RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }]
624        ));
625    }
626
627    #[test]
628    fn stderr_becomes_truncated_activity() {
629        let long = "x".repeat(500);
630        let events = normalize_process_event(
631            Event::Stderr {
632                run_id: "r1".into(),
633                line: long,
634            },
635            empty_parser,
636        );
637        match events.as_slice() {
638            [RunEvent::Activity { run_id, message }] => {
639                assert_eq!(run_id, "r1");
640                assert_eq!(message.chars().count(), 240);
641            }
642            other => panic!("expected one Activity, got {other:?}"),
643        }
644        // Empty stderr line → no event.
645        assert!(normalize_process_event(
646            Event::Stderr {
647                run_id: "r1".into(),
648                line: String::new(),
649            },
650            empty_parser,
651        )
652        .is_empty());
653    }
654
655    #[test]
656    fn thinking_normalizes_and_serializes() {
657        let events = normalize_process_event(
658            Event::Stdout {
659                run_id: "r1".to_owned(),
660                line: "ignored".to_owned(),
661            },
662            |_| ParsedLine {
663                thinking: Some("pondering".to_owned()),
664                ..ParsedLine::default()
665            },
666        );
667        assert!(matches!(
668            events.as_slice(),
669            [RunEvent::Thinking { run_id, delta }] if run_id == "r1" && delta == "pondering"
670        ));
671        let json = serde_json::to_value(RunEvent::Thinking {
672            run_id: "r1".to_owned(),
673            delta: "d".to_owned(),
674        })
675        .unwrap();
676        assert_eq!(json["kind"], "thinking");
677        assert_eq!(json["runId"], "r1");
678        assert_eq!(json["delta"], "d");
679    }
680
681    #[test]
682    fn run_event_serializes_with_kind_and_camelcase() {
683        let json = serde_json::to_value(RunEvent::Exited {
684            run_id: "r1".to_owned(),
685            exit_code: Some(2),
686            cancelled: true,
687        })
688        .unwrap();
689        assert_eq!(json["kind"], "exited");
690        assert_eq!(json["runId"], "r1");
691        assert_eq!(json["exitCode"], 2);
692        assert_eq!(json["cancelled"], true);
693    }
694
695    #[test]
696    fn ask_question_serializes_with_camelcase_and_skips_empty_description() {
697        let json = serde_json::to_value(RunEvent::AskQuestion {
698            run_id: "r1".to_owned(),
699            request_id: "q-7".to_owned(),
700            questions: vec![Question {
701                header: Some("Scope".to_owned()),
702                prompt: "Which files?".to_owned(),
703                options: vec![
704                    QuestionOption { label: "All".to_owned(), description: None },
705                    QuestionOption {
706                        label: "Changed only".to_owned(),
707                        description: Some("Just the diff".to_owned()),
708                    },
709                ],
710                multi_select: true,
711                allow_free_text: false,
712            }],
713        })
714        .unwrap();
715        assert_eq!(json["kind"], "askQuestion");
716        assert_eq!(json["runId"], "r1");
717        assert_eq!(json["requestId"], "q-7");
718        let q = &json["questions"][0];
719        assert_eq!(q["header"], "Scope");
720        assert_eq!(q["prompt"], "Which files?");
721        assert_eq!(q["multiSelect"], true);
722        assert_eq!(q["allowFreeText"], false);
723        assert_eq!(q["options"][0]["label"], "All");
724        // A `None` description is omitted from the wire (skip_serializing_if).
725        assert!(q["options"][0].get("description").is_none());
726        assert_eq!(q["options"][1]["description"], "Just the diff");
727    }
728
729    #[test]
730    fn session_normalizes_and_serializes() {
731        let events = normalize_process_event(
732            Event::Stdout {
733                run_id: "r1".to_owned(),
734                line: "ignored".to_owned(),
735            },
736            |_| ParsedLine {
737                session: Some(SessionInfo {
738                    session_id: Some("sess-1".to_owned()),
739                    model: Some("opus".to_owned()),
740                }),
741                ..ParsedLine::default()
742            },
743        );
744        assert!(matches!(
745            events.as_slice(),
746            [RunEvent::Session { run_id, session_id, model }]
747                if run_id == "r1"
748                    && session_id.as_deref() == Some("sess-1")
749                    && model.as_deref() == Some("opus")
750        ));
751        let json = serde_json::to_value(RunEvent::Session {
752            run_id: "r1".to_owned(),
753            session_id: Some("sess-1".to_owned()),
754            model: None,
755        })
756        .unwrap();
757        assert_eq!(json["kind"], "session");
758        assert_eq!(json["sessionId"], "sess-1");
759        // model omitted from the wire when None (backward-compatible).
760        assert!(json.get("model").is_none());
761    }
762
763    #[test]
764    fn usage_normalizes_and_serializes() {
765        let events = normalize_process_event(
766            Event::Stdout {
767                run_id: "r1".to_owned(),
768                line: "ignored".to_owned(),
769            },
770            |_| ParsedLine {
771                usage: Some(UsageInfo {
772                    input_tokens: Some(10),
773                    output_tokens: Some(20),
774                    total_tokens: Some(30),
775                    cache_read_tokens: Some(8),
776                    cache_write_tokens: Some(2),
777                }),
778                ..ParsedLine::default()
779            },
780        );
781        assert!(matches!(
782            events.as_slice(),
783            [RunEvent::Usage {
784                run_id,
785                input_tokens: Some(10),
786                output_tokens: Some(20),
787                total_tokens: Some(30),
788                cache_read_tokens: Some(8),
789                cache_write_tokens: Some(2),
790                cost_usd: None,
791            }] if run_id == "r1"
792        ));
793        // Cache counters ride on the wire as camelCase; omitted when None.
794        let json = serde_json::to_value(RunEvent::Usage {
795            run_id: "r1".to_owned(),
796            input_tokens: Some(10),
797            output_tokens: None,
798            total_tokens: Some(30),
799            cache_read_tokens: Some(8),
800            cache_write_tokens: None,
801            cost_usd: None,
802        })
803        .unwrap();
804        assert_eq!(json["kind"], "usage");
805        assert_eq!(json["inputTokens"], 10);
806        assert_eq!(json["totalTokens"], 30);
807        assert_eq!(json["cacheReadTokens"], 8);
808        assert!(json.get("outputTokens").is_none()); // omitted when None
809        assert!(json.get("cacheWriteTokens").is_none()); // omitted when None
810    }
811
812    #[test]
813    fn plan_normalizes_and_serializes() {
814        let events = normalize_process_event(
815            Event::Stdout {
816                run_id: "r1".to_owned(),
817                line: "ignored".to_owned(),
818            },
819            |_| ParsedLine {
820                plan: Some(vec![
821                    PlanEntry {
822                        content: "Write the parser".to_owned(),
823                        status: PlanEntryStatus::InProgress,
824                        priority: Some(PlanEntryPriority::High),
825                    },
826                    PlanEntry {
827                        content: "Add tests".to_owned(),
828                        status: PlanEntryStatus::Pending,
829                        priority: None,
830                    },
831                ]),
832                ..ParsedLine::default()
833            },
834        );
835        assert!(matches!(
836            events.as_slice(),
837            [RunEvent::Plan { run_id, entries }] if run_id == "r1" && entries.len() == 2
838        ));
839        let json = serde_json::to_value(RunEvent::Plan {
840            run_id: "r1".to_owned(),
841            entries: vec![PlanEntry {
842                content: "Add tests".to_owned(),
843                status: PlanEntryStatus::Pending,
844                priority: None,
845            }],
846        })
847        .unwrap();
848        assert_eq!(json["kind"], "plan");
849        assert_eq!(json["entries"][0]["content"], "Add tests");
850        assert_eq!(json["entries"][0]["status"], "pending");
851        // priority omitted when None.
852        assert!(json["entries"][0].get("priority").is_none());
853    }
854
855    #[test]
856    fn session_info_update_serializes() {
857        let json = serde_json::to_value(RunEvent::SessionInfoUpdate {
858            run_id: "r1".to_owned(),
859            title: Some("Refactor the parser".to_owned()),
860            updated_at: Some("2026-06-16T12:00:00Z".to_owned()),
861        })
862        .unwrap();
863        assert_eq!(json["kind"], "sessionInfoUpdate");
864        assert_eq!(json["runId"], "r1");
865        assert_eq!(json["title"], "Refactor the parser");
866        // Maps onto ACP `session_info_update.updatedAt`.
867        assert_eq!(json["updatedAt"], "2026-06-16T12:00:00Z");
868    }
869
870    #[test]
871    fn tool_io_is_carried_and_omitted_when_absent() {
872        // input on ToolStart, output on ToolEnd — distinct events, distinct moments.
873        let start = normalize_process_event(
874            Event::Stdout {
875                run_id: "r1".to_owned(),
876                line: "ignored".to_owned(),
877            },
878            |_| ParsedLine {
879                tool_start: Some(ToolCallStart {
880                    tool_call_id: "t1".to_owned(),
881                    name: "ls".to_owned(),
882                    input: Some("{\"dir\":\"/x\"}".to_owned()),
883                    tool_kind: ToolKind::Other,
884                }),
885                ..ParsedLine::default()
886            },
887        );
888        assert!(matches!(
889            start.as_slice(),
890            [RunEvent::ToolStart { raw_input: Some(i), .. }] if i == "{\"dir\":\"/x\"}"
891        ));
892        // A ToolStart with no input omits the field on the wire (byte-identical
893        // to the pre-enrichment shape).
894        let json = serde_json::to_value(RunEvent::ToolStart {
895            run_id: "r1".to_owned(),
896            tool_call_id: "t1".to_owned(),
897            title: "ls".to_owned(),
898            tool_kind: ToolKind::Execute,
899            locations: Vec::new(),
900            raw_input: None,
901        })
902        .unwrap();
903        assert_eq!(json["kind"], "toolStart");
904        // The neutral class rides alongside as `toolKind` — distinct wire key
905        // from the `kind` event discriminator (no collision).
906        assert_eq!(json["toolKind"], "execute");
907        assert_eq!(json["toolCallId"], "t1");
908        assert!(json.get("rawInput").is_none());
909
910        let json = serde_json::to_value(RunEvent::ToolEnd {
911            run_id: "r1".to_owned(),
912            tool_call_id: "t1".to_owned(),
913            ok: true,
914            content: Some("done".to_owned()),
915            raw_output: None,
916            locations: Vec::new(),
917        })
918        .unwrap();
919        assert_eq!(json["kind"], "toolEnd");
920        assert_eq!(json["content"], "done");
921    }
922
923    #[test]
924    fn parsed_error_normalizes_to_run_event_error() {
925        // An in-band failure decoded onto a ParsedLine surfaces as
926        // RunEvent::Error — the path codex's `turn.failed` / `error` lines now
927        // take, so a failed turn no longer yields neither answer nor error.
928        let events = normalize_process_event(
929            Event::Stdout {
930                run_id: "r1".to_owned(),
931                line: "ignored".to_owned(),
932            },
933            |_| ParsedLine {
934                error: Some("rate limited".to_owned()),
935                ..ParsedLine::default()
936            },
937        );
938        assert!(matches!(
939            events.as_slice(),
940            [RunEvent::Error { run_id, message }] if run_id == "r1" && message == "rate limited"
941        ));
942        // An error-only line is not empty (is_empty stays honest).
943        assert!(!ParsedLine {
944            error: Some("x".to_owned()),
945            ..ParsedLine::default()
946        }
947        .is_empty());
948    }
949
950    #[test]
951    fn suggested_edits_event_serializes_camelcase() {
952        let json = serde_json::to_value(RunEvent::SuggestedEdits {
953            run_id: "r1".to_owned(),
954            edits: vec![SuggestedEdit {
955                file_path: "a.md".to_owned(),
956                range: ByteRange { start: 1, end: 2 },
957                replacement: "x".to_owned(),
958                title: None,
959            }],
960        })
961        .unwrap();
962        assert_eq!(json["kind"], "suggestedEdits");
963        assert_eq!(json["edits"][0]["filePath"], "a.md");
964        assert_eq!(json["edits"][0]["range"]["start"], 1);
965        // title omitted when None
966        assert!(json["edits"][0].get("title").is_none());
967    }
968}