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}