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///
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 `ProcessEvent` 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    /// Spawn / 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: ProcessEvent,
434    mut parse_line: impl FnMut(&str) -> ParsedLine,
435) -> Vec<RunEvent> {
436    match event {
437        ProcessEvent::Started { run_id } => vec![RunEvent::Started { run_id }],
438        ProcessEvent::Exited {
439            run_id,
440            exit_code,
441            cancelled,
442        } => vec![RunEvent::Exited {
443            run_id,
444            exit_code,
445            cancelled,
446        }],
447        ProcessEvent::Error { run_id, message } => vec![RunEvent::Error { run_id, message }],
448        ProcessEvent::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 {
455                vec![RunEvent::Activity { run_id, message }]
456            }
457        }
458        ProcessEvent::Stdout { run_id, line } => run_events_from_parsed(&run_id, parse_line(&line)),
459        // `ProcessEvent` is #[non_exhaustive]; a future variant yields no
460        // events until an adapter learns to handle it.
461        _ => Vec::new(),
462    }
463}
464
465/// Expand a decoded [`ParsedLine`] into its [`RunEvent`]s for `run_id`, in a
466/// stable order: session (the run's init) → session-info/title → text →
467/// thinking → tool start/end → edits → plan → usage (end of turn) → activity
468/// → error (a terminal in-band failure, emitted last so any text/usage on the
469/// same line lands before it) → ask-question.
470///
471/// Used by [`normalize_process_event`] and by adapters that wrap the line
472/// parser in their own per-run state (e.g. codex's preamble-vs-answer state
473/// machine, which decides *where* a message goes but still relies on this
474/// for everything else) — so the `ParsedLine` → `RunEvent` mapping lives in
475/// exactly one place.
476///
477/// Public so an **out-of-tree** harness can build a stateful parser the same
478/// way: decide your own routing per line, then call this to expand a
479/// `ParsedLine` into events with the canonical ordering — instead of
480/// hand-rolling (and drifting from) the mapping. See `examples/custom_harness.rs`.
481pub fn run_events_from_parsed(run_id: &str, parsed: ParsedLine) -> Vec<RunEvent> {
482    let mut out = Vec::new();
483    if let Some(session) = parsed.session {
484        out.push(RunEvent::Session {
485            run_id: run_id.to_owned(),
486            session_id: session.session_id,
487            model: session.model,
488        });
489    }
490    if let Some(title) = parsed.title {
491        out.push(RunEvent::SessionInfoUpdate {
492            run_id: run_id.to_owned(),
493            title: Some(title),
494            // No parser supplies a timestamp yet; an adapter that has one
495            // constructs SessionInfoUpdate directly.
496            updated_at: None,
497        });
498    }
499    if let Some(text) = parsed.text {
500        out.push(RunEvent::Text {
501            run_id: run_id.to_owned(),
502            delta: text,
503        });
504    }
505    if let Some(thinking) = parsed.thinking {
506        out.push(RunEvent::Thinking {
507            run_id: run_id.to_owned(),
508            delta: thinking,
509        });
510    }
511    if let Some(start) = parsed.tool_start {
512        out.push(RunEvent::ToolStart {
513            run_id: run_id.to_owned(),
514            tool_call_id: start.tool_call_id,
515            title: start.name,
516            tool_kind: start.tool_kind,
517            // CLI adapters don't report structured paths; ACP + openai-compatible do.
518            locations: Vec::new(),
519            raw_input: start.input,
520        });
521    }
522    if let Some(end) = parsed.tool_end {
523        out.push(RunEvent::ToolEnd {
524            run_id: run_id.to_owned(),
525            tool_call_id: end.tool_call_id,
526            ok: end.ok,
527            content: end.output,
528            raw_output: None,
529            locations: Vec::new(),
530        });
531    }
532    if !parsed.edits.is_empty() {
533        out.push(RunEvent::SuggestedEdits {
534            run_id: run_id.to_owned(),
535            edits: parsed.edits,
536        });
537    }
538    if let Some(entries) = parsed.plan {
539        out.push(RunEvent::Plan {
540            run_id: run_id.to_owned(),
541            entries,
542        });
543    }
544    if let Some(usage) = parsed.usage {
545        out.push(RunEvent::Usage {
546            run_id: run_id.to_owned(),
547            input_tokens: usage.input_tokens,
548            output_tokens: usage.output_tokens,
549            total_tokens: usage.total_tokens,
550            cache_read_tokens: usage.cache_read_tokens,
551            cache_write_tokens: usage.cache_write_tokens,
552            // CLI adapters don't compute cost; openai-compatible does (with rates).
553            cost_usd: None,
554        });
555    }
556    if let Some(activity) = parsed.activity {
557        out.push(RunEvent::Activity {
558            run_id: run_id.to_owned(),
559            message: activity,
560        });
561    }
562    // A harness reported an in-band failure on its stdout. This is the one
563    // place a parsed line can become `RunEvent::Error` (the only other source
564    // is a process-level `ProcessEvent::Error`), so an in-band failure from any
565    // harness — not just a spawn/IO failure — reaches the consumer.
566    if let Some(message) = parsed.error {
567        out.push(RunEvent::Error {
568            run_id: run_id.to_owned(),
569            message,
570        });
571    }
572    if let Some((request_id, questions)) = parsed.ask_question {
573        out.push(RunEvent::AskQuestion {
574            run_id: run_id.to_owned(),
575            request_id,
576            questions,
577        });
578    }
579    out
580}
581
582/// Take the first `max_chars` characters (not bytes) of `s`. Bounds the
583/// stderr activity line without splitting a multi-byte char.
584fn truncate(s: &str, max_chars: usize) -> String {
585    s.chars().take(max_chars).collect()
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    /// A line parser that yields nothing — exercises the neutral
593    /// skeleton without any harness-specific decoding.
594    fn empty_parser(_: &str) -> ParsedLine {
595        ParsedLine::default()
596    }
597
598    #[test]
599    fn normalize_passes_through_lifecycle_events() {
600        assert!(matches!(
601            normalize_process_event(ProcessEvent::Started { run_id: "r".into() }, empty_parser)
602                .as_slice(),
603            [RunEvent::Started { .. }]
604        ));
605        assert!(matches!(
606            normalize_process_event(
607                ProcessEvent::Exited {
608                    run_id: "r".into(),
609                    exit_code: Some(0),
610                    cancelled: false
611                },
612                empty_parser
613            )
614            .as_slice(),
615            [RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }]
616        ));
617    }
618
619    #[test]
620    fn stderr_becomes_truncated_activity() {
621        let long = "x".repeat(500);
622        let events = normalize_process_event(
623            ProcessEvent::Stderr {
624                run_id: "r1".into(),
625                line: long,
626            },
627            empty_parser,
628        );
629        match events.as_slice() {
630            [RunEvent::Activity { run_id, message }] => {
631                assert_eq!(run_id, "r1");
632                assert_eq!(message.chars().count(), 240);
633            }
634            other => panic!("expected one Activity, got {other:?}"),
635        }
636        // Empty stderr line → no event.
637        assert!(normalize_process_event(
638            ProcessEvent::Stderr {
639                run_id: "r1".into(),
640                line: String::new(),
641            },
642            empty_parser,
643        )
644        .is_empty());
645    }
646
647    #[test]
648    fn thinking_normalizes_and_serializes() {
649        let events = normalize_process_event(
650            ProcessEvent::Stdout {
651                run_id: "r1".to_owned(),
652                line: "ignored".to_owned(),
653            },
654            |_| ParsedLine {
655                thinking: Some("pondering".to_owned()),
656                ..ParsedLine::default()
657            },
658        );
659        assert!(matches!(
660            events.as_slice(),
661            [RunEvent::Thinking { run_id, delta }] if run_id == "r1" && delta == "pondering"
662        ));
663        let json = serde_json::to_value(RunEvent::Thinking {
664            run_id: "r1".to_owned(),
665            delta: "d".to_owned(),
666        })
667        .unwrap();
668        assert_eq!(json["kind"], "thinking");
669        assert_eq!(json["runId"], "r1");
670        assert_eq!(json["delta"], "d");
671    }
672
673    #[test]
674    fn run_event_serializes_with_kind_and_camelcase() {
675        let json = serde_json::to_value(RunEvent::Exited {
676            run_id: "r1".to_owned(),
677            exit_code: Some(2),
678            cancelled: true,
679        })
680        .unwrap();
681        assert_eq!(json["kind"], "exited");
682        assert_eq!(json["runId"], "r1");
683        assert_eq!(json["exitCode"], 2);
684        assert_eq!(json["cancelled"], true);
685    }
686
687    #[test]
688    fn ask_question_serializes_with_camelcase_and_skips_empty_description() {
689        let json = serde_json::to_value(RunEvent::AskQuestion {
690            run_id: "r1".to_owned(),
691            request_id: "q-7".to_owned(),
692            questions: vec![Question {
693                header: Some("Scope".to_owned()),
694                prompt: "Which files?".to_owned(),
695                options: vec![
696                    QuestionOption { label: "All".to_owned(), description: None },
697                    QuestionOption {
698                        label: "Changed only".to_owned(),
699                        description: Some("Just the diff".to_owned()),
700                    },
701                ],
702                multi_select: true,
703                allow_free_text: false,
704            }],
705        })
706        .unwrap();
707        assert_eq!(json["kind"], "askQuestion");
708        assert_eq!(json["runId"], "r1");
709        assert_eq!(json["requestId"], "q-7");
710        let q = &json["questions"][0];
711        assert_eq!(q["header"], "Scope");
712        assert_eq!(q["prompt"], "Which files?");
713        assert_eq!(q["multiSelect"], true);
714        assert_eq!(q["allowFreeText"], false);
715        assert_eq!(q["options"][0]["label"], "All");
716        // A `None` description is omitted from the wire (skip_serializing_if).
717        assert!(q["options"][0].get("description").is_none());
718        assert_eq!(q["options"][1]["description"], "Just the diff");
719    }
720
721    #[test]
722    fn session_normalizes_and_serializes() {
723        let events = normalize_process_event(
724            ProcessEvent::Stdout {
725                run_id: "r1".to_owned(),
726                line: "ignored".to_owned(),
727            },
728            |_| ParsedLine {
729                session: Some(SessionInfo {
730                    session_id: Some("sess-1".to_owned()),
731                    model: Some("opus".to_owned()),
732                }),
733                ..ParsedLine::default()
734            },
735        );
736        assert!(matches!(
737            events.as_slice(),
738            [RunEvent::Session { run_id, session_id, model }]
739                if run_id == "r1"
740                    && session_id.as_deref() == Some("sess-1")
741                    && model.as_deref() == Some("opus")
742        ));
743        let json = serde_json::to_value(RunEvent::Session {
744            run_id: "r1".to_owned(),
745            session_id: Some("sess-1".to_owned()),
746            model: None,
747        })
748        .unwrap();
749        assert_eq!(json["kind"], "session");
750        assert_eq!(json["sessionId"], "sess-1");
751        // model omitted from the wire when None (backward-compatible).
752        assert!(json.get("model").is_none());
753    }
754
755    #[test]
756    fn usage_normalizes_and_serializes() {
757        let events = normalize_process_event(
758            ProcessEvent::Stdout {
759                run_id: "r1".to_owned(),
760                line: "ignored".to_owned(),
761            },
762            |_| ParsedLine {
763                usage: Some(UsageInfo {
764                    input_tokens: Some(10),
765                    output_tokens: Some(20),
766                    total_tokens: Some(30),
767                    cache_read_tokens: Some(8),
768                    cache_write_tokens: Some(2),
769                }),
770                ..ParsedLine::default()
771            },
772        );
773        assert!(matches!(
774            events.as_slice(),
775            [RunEvent::Usage {
776                run_id,
777                input_tokens: Some(10),
778                output_tokens: Some(20),
779                total_tokens: Some(30),
780                cache_read_tokens: Some(8),
781                cache_write_tokens: Some(2),
782                cost_usd: None,
783            }] if run_id == "r1"
784        ));
785        // Cache counters ride on the wire as camelCase; omitted when None.
786        let json = serde_json::to_value(RunEvent::Usage {
787            run_id: "r1".to_owned(),
788            input_tokens: Some(10),
789            output_tokens: None,
790            total_tokens: Some(30),
791            cache_read_tokens: Some(8),
792            cache_write_tokens: None,
793            cost_usd: None,
794        })
795        .unwrap();
796        assert_eq!(json["kind"], "usage");
797        assert_eq!(json["inputTokens"], 10);
798        assert_eq!(json["totalTokens"], 30);
799        assert_eq!(json["cacheReadTokens"], 8);
800        assert!(json.get("outputTokens").is_none()); // omitted when None
801        assert!(json.get("cacheWriteTokens").is_none()); // omitted when None
802    }
803
804    #[test]
805    fn plan_normalizes_and_serializes() {
806        let events = normalize_process_event(
807            ProcessEvent::Stdout {
808                run_id: "r1".to_owned(),
809                line: "ignored".to_owned(),
810            },
811            |_| ParsedLine {
812                plan: Some(vec![
813                    PlanEntry {
814                        content: "Write the parser".to_owned(),
815                        status: PlanEntryStatus::InProgress,
816                        priority: Some(PlanEntryPriority::High),
817                    },
818                    PlanEntry {
819                        content: "Add tests".to_owned(),
820                        status: PlanEntryStatus::Pending,
821                        priority: None,
822                    },
823                ]),
824                ..ParsedLine::default()
825            },
826        );
827        assert!(matches!(
828            events.as_slice(),
829            [RunEvent::Plan { run_id, entries }] if run_id == "r1" && entries.len() == 2
830        ));
831        let json = serde_json::to_value(RunEvent::Plan {
832            run_id: "r1".to_owned(),
833            entries: vec![PlanEntry {
834                content: "Add tests".to_owned(),
835                status: PlanEntryStatus::Pending,
836                priority: None,
837            }],
838        })
839        .unwrap();
840        assert_eq!(json["kind"], "plan");
841        assert_eq!(json["entries"][0]["content"], "Add tests");
842        assert_eq!(json["entries"][0]["status"], "pending");
843        // priority omitted when None.
844        assert!(json["entries"][0].get("priority").is_none());
845    }
846
847    #[test]
848    fn session_info_update_serializes() {
849        let json = serde_json::to_value(RunEvent::SessionInfoUpdate {
850            run_id: "r1".to_owned(),
851            title: Some("Refactor the parser".to_owned()),
852            updated_at: Some("2026-06-16T12:00:00Z".to_owned()),
853        })
854        .unwrap();
855        assert_eq!(json["kind"], "sessionInfoUpdate");
856        assert_eq!(json["runId"], "r1");
857        assert_eq!(json["title"], "Refactor the parser");
858        // Maps onto ACP `session_info_update.updatedAt`.
859        assert_eq!(json["updatedAt"], "2026-06-16T12:00:00Z");
860    }
861
862    #[test]
863    fn tool_io_is_carried_and_omitted_when_absent() {
864        // input on ToolStart, output on ToolEnd — distinct events, distinct moments.
865        let start = normalize_process_event(
866            ProcessEvent::Stdout {
867                run_id: "r1".to_owned(),
868                line: "ignored".to_owned(),
869            },
870            |_| ParsedLine {
871                tool_start: Some(ToolCallStart {
872                    tool_call_id: "t1".to_owned(),
873                    name: "ls".to_owned(),
874                    input: Some("{\"dir\":\"/x\"}".to_owned()),
875                    tool_kind: ToolKind::Other,
876                }),
877                ..ParsedLine::default()
878            },
879        );
880        assert!(matches!(
881            start.as_slice(),
882            [RunEvent::ToolStart { raw_input: Some(i), .. }] if i == "{\"dir\":\"/x\"}"
883        ));
884        // A ToolStart with no input omits the field on the wire (byte-identical
885        // to the pre-enrichment shape).
886        let json = serde_json::to_value(RunEvent::ToolStart {
887            run_id: "r1".to_owned(),
888            tool_call_id: "t1".to_owned(),
889            title: "ls".to_owned(),
890            tool_kind: ToolKind::Execute,
891            locations: Vec::new(),
892            raw_input: None,
893        })
894        .unwrap();
895        assert_eq!(json["kind"], "toolStart");
896        // The neutral class rides alongside as `toolKind` — distinct wire key
897        // from the `kind` event discriminator (no collision).
898        assert_eq!(json["toolKind"], "execute");
899        assert_eq!(json["toolCallId"], "t1");
900        assert!(json.get("rawInput").is_none());
901
902        let json = serde_json::to_value(RunEvent::ToolEnd {
903            run_id: "r1".to_owned(),
904            tool_call_id: "t1".to_owned(),
905            ok: true,
906            content: Some("done".to_owned()),
907            raw_output: None,
908            locations: Vec::new(),
909        })
910        .unwrap();
911        assert_eq!(json["kind"], "toolEnd");
912        assert_eq!(json["content"], "done");
913    }
914
915    #[test]
916    fn parsed_error_normalizes_to_run_event_error() {
917        // An in-band failure decoded onto a ParsedLine surfaces as
918        // RunEvent::Error — the path codex's `turn.failed` / `error` lines now
919        // take, so a failed turn no longer yields neither answer nor error.
920        let events = normalize_process_event(
921            ProcessEvent::Stdout {
922                run_id: "r1".to_owned(),
923                line: "ignored".to_owned(),
924            },
925            |_| ParsedLine {
926                error: Some("rate limited".to_owned()),
927                ..ParsedLine::default()
928            },
929        );
930        assert!(matches!(
931            events.as_slice(),
932            [RunEvent::Error { run_id, message }] if run_id == "r1" && message == "rate limited"
933        ));
934        // An error-only line is not empty (is_empty stays honest).
935        assert!(!ParsedLine {
936            error: Some("x".to_owned()),
937            ..ParsedLine::default()
938        }
939        .is_empty());
940    }
941
942    #[test]
943    fn suggested_edits_event_serializes_camelcase() {
944        let json = serde_json::to_value(RunEvent::SuggestedEdits {
945            run_id: "r1".to_owned(),
946            edits: vec![SuggestedEdit {
947                file_path: "a.md".to_owned(),
948                range: ByteRange { start: 1, end: 2 },
949                replacement: "x".to_owned(),
950                title: None,
951            }],
952        })
953        .unwrap();
954        assert_eq!(json["kind"], "suggestedEdits");
955        assert_eq!(json["edits"][0]["filePath"], "a.md");
956        assert_eq!(json["edits"][0]["range"]["start"], 1);
957        // title omitted when None
958        assert!(json["edits"][0].get("title").is_none());
959    }
960}