From c85df76f8723f6ce4c9482db09265a4b2eb0d14b Mon Sep 17 00:00:00 2001 From: emal Date: Thu, 23 Apr 2026 11:35:41 -0700 Subject: [PATCH] feat(output): emit turn_start event on JSONL stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JsonStreamSink::on_turn_start only updated internal state, never emitting a line. Consumers of the JSONL stream could see TurnComplete arrive at the end of a turn but had no signal that a new turn had begun — so a real-time progress indicator ("turn 3 of 10, still working...") couldn't render without racing a TextDelta or ToolCall event. Adds Event::TurnStart { turn } with a minimal envelope (only type + turn). The event fires immediately after the internal turn counter is updated, so the line carries the new turn number and consumers can correlate it with the matching TurnComplete bookend. Stderr is unchanged; this is purely additive on stdout. Two new tests cover: snake_case type tag + turn field shape, and an envelope-size guard that will fail if a future refactor accidentally adds model/session fields to TurnStart (consumers snapshot envelope shapes). The existing all_events_are_single_line_json check is extended to include TurnStart. --- crates/cli/src/output.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index 7c38f64..31aba65 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -70,6 +70,12 @@ enum Event<'a> { model: &'a str, timestamp: &'a str, }, + /// A new agent turn has just begun. Fires BEFORE any text or tool + /// activity for this turn, so consumers can render a "turn N" + /// progress indicator without waiting for TurnComplete. + TurnStart { + turn: usize, + }, TextDelta { content: &'a str, turn: usize, @@ -171,6 +177,10 @@ impl JsonStreamSink { impl StreamSink for JsonStreamSink { fn on_turn_start(&self, turn: usize) { self.inner.lock().unwrap().turn = turn; + // Emit after updating state so downstream consumers see a + // turn_start line carrying the new turn number. TurnComplete + // already fires at the end; this is the matching bookend. + emit(&Event::TurnStart { turn }); } fn on_text(&self, text: &str) { @@ -354,6 +364,7 @@ mod tests { timestamp: "t", }) .unwrap(), + serde_json::to_value(Event::TurnStart { turn: 1 }).unwrap(), serde_json::to_value(Event::TextDelta { content: "multi\nline\ncontent", turn: 1, @@ -382,6 +393,27 @@ mod tests { } } + #[test] + fn event_serialization_turn_start_uses_snake_case_type() { + let event = Event::TurnStart { turn: 5 }; + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains(r#""type":"turn_start""#)); + assert!(json.contains(r#""turn":5"#)); + } + + #[test] + fn turn_start_event_does_not_include_model_or_session_fields() { + // TurnStart is intentionally small — just the turn number. If + // a field accidentally slips in (e.g. model), JSONL consumers + // that snapshot the envelope shape will start silently failing. + let event = Event::TurnStart { turn: 1 }; + let val = serde_json::to_value(event).unwrap(); + let obj = val.as_object().unwrap(); + assert_eq!(obj.len(), 2, "expected only `type` and `turn`, got {obj:?}"); + assert!(obj.contains_key("type")); + assert!(obj.contains_key("turn")); + } + #[test] fn event_serialization_warning_uses_snake_case_type() { let event = Event::Warning {