From 5cd9d097764c37a80c9e75ecaed94b8bc362fcc2 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 22 Dec 2025 12:29:26 +1100 Subject: [PATCH 1/3] option to stream json - jsonl really --- crates/goose-cli/src/cli.rs | 6 +- crates/goose-cli/src/session/mod.rs | 129 ++++++++++++++++++++++------ 2 files changed, 108 insertions(+), 27 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 3d9af20a2811..cb664c49ace2 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -753,13 +753,13 @@ enum Command { )] additional_sub_recipes: Vec, - /// Output format (text, json) + /// Output format (text, json, stream-json) #[arg( long = "output-format", value_name = "FORMAT", - help = "Output format (text, json)", + help = "Output format (text, json, stream-json)", default_value = "text", - value_parser = clap::builder::PossibleValuesParser::new(["text", "json"]) + value_parser = clap::builder::PossibleValuesParser::new(["text", "json", "stream-json"]) )] output_format: String, diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index a9a3875702df..a324abae3d88 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -65,6 +65,42 @@ struct JsonMetadata { status: String, } +#[derive(Serialize, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +enum StreamEvent { + Message { + message: Message, + }, + Notification { + extension_id: String, + #[serde(flatten)] + data: NotificationData, + }, + ModelChange { + model: String, + mode: String, + }, + Error { + error: String, + }, + Complete { + total_tokens: Option, + }, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +enum NotificationData { + Log { + message: String, + }, + Progress { + progress: f64, + total: Option, + message: Option, + }, +} + pub enum RunMode { Normal, Plan, @@ -814,6 +850,14 @@ impl CliSession { ) -> Result<()> { // Cache the output format check to avoid repeated string comparisons in the hot loop let is_json_mode = self.output_format == "json"; + let is_stream_json_mode = self.output_format == "stream-json"; + + // Helper to emit a streaming JSON event + let emit_stream_event = |event: &StreamEvent| { + if let Ok(json) = serde_json::to_string(event) { + println!("{}", json); + } + }; let session_config = SessionConfig { id: self.session_id.clone(), @@ -1027,13 +1071,15 @@ impl CliSession { if interactive {output::hide_thinking()}; let _ = progress_bars.hide(); - // Don't render in JSON mode - if !is_json_mode { + // Handle different output formats + if is_stream_json_mode { + emit_stream_event(&StreamEvent::Message { message: message.clone() }); + } else if !is_json_mode { output::render_message(&message, self.debug); } } } - Some(Ok(AgentEvent::McpNotification((_id, message)))) => { + Some(Ok(AgentEvent::McpNotification((extension_id, message)))) => { match &message { ServerNotification::LoggingMessageNotification(notification) => { let data = ¬ification.params.data; @@ -1101,9 +1147,15 @@ impl CliSession { }, }; + // Handle stream-json mode for notifications + if is_stream_json_mode { + emit_stream_event(&StreamEvent::Notification { + extension_id: extension_id.clone(), + data: NotificationData::Log { message: formatted_message.clone() }, + }); + } // Handle subagent notifications - show immediately - if let Some(_id) = subagent_id { - // TODO: proper display for subagent notifications + else if let Some(_id) = subagent_id { if interactive { let _ = progress_bars.hide(); if !is_json_mode { @@ -1125,7 +1177,6 @@ impl CliSession { std::io::stdout().flush().unwrap(); } } else if notification_type == "shell_output" { - // Hide spinner, print shell output, spinner will resume if interactive { let _ = progress_bars.hide(); } @@ -1145,12 +1196,24 @@ impl CliSession { let text = notification.params.message.as_deref(); let total = notification.params.total; let token = ¬ification.params.progress_token; - progress_bars.update( - &token.0.to_string(), - progress, - total, - text, - ); + + if is_stream_json_mode { + emit_stream_event(&StreamEvent::Notification { + extension_id: extension_id.clone(), + data: NotificationData::Progress { + progress, + total, + message: text.map(String::from), + }, + }); + } else { + progress_bars.update( + &token.0.to_string(), + progress, + total, + text, + ); + } }, _ => (), } @@ -1159,32 +1222,44 @@ impl CliSession { self.messages = updated_conversation; } Some(Ok(AgentEvent::ModelChange { model, mode })) => { - // Log model change if in debug mode - if self.debug { + if is_stream_json_mode { + emit_stream_event(&StreamEvent::ModelChange { + model: model.clone(), + mode: mode.clone(), + }); + } else if self.debug { eprintln!("Model changed to {} in {} mode", model, mode); } } Some(Err(e)) => { - // TODO(Douwe): Delete this - // Check if it's a ProviderError::ContextLengthExceeded + let error_msg = e.to_string(); + + if is_stream_json_mode { + emit_stream_event(&StreamEvent::Error { error: error_msg.clone() }); + } + if e.downcast_ref::() .map(|provider_error| matches!(provider_error, goose::providers::errors::ProviderError::ContextLengthExceeded(_))) .unwrap_or(false) { - output::render_text( - "Compaction requested. Should have happened in the agent!", - Some(Color::Yellow), - true - ); + if !is_stream_json_mode { + output::render_text( + "Compaction requested. Should have happened in the agent!", + Some(Color::Yellow), + true + ); + } warn!("Compaction requested. Should have happened in the agent!"); } - eprintln!("Error: {}", e); + if !is_stream_json_mode { + eprintln!("Error: {}", error_msg); + } cancel_token_clone.cancel(); drop(stream); if let Err(e) = self.handle_interrupted_messages(false).await { eprintln!("Error handling interruption: {}", e); - } else { + } else if !is_stream_json_mode { output::render_error( "The error above was an exception we were not able to handle.\n\ These errors are often related to connection or authentication\n\ @@ -1207,7 +1282,7 @@ impl CliSession { } } - // Output JSON if requested + // Output based on format if is_json_mode { let metadata = match SessionManager::get_session(&self.session_id, false).await { Ok(session) => JsonMetadata { @@ -1226,6 +1301,12 @@ impl CliSession { }; println!("{}", serde_json::to_string_pretty(&json_output)?); + } else if is_stream_json_mode { + let total_tokens = SessionManager::get_session(&self.session_id, false) + .await + .ok() + .and_then(|s| s.total_tokens); + emit_stream_event(&StreamEvent::Complete { total_tokens }); } else { println!(); } From 4e2c021355093c0119a4eb3017107b56bec51339 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 23 Dec 2025 10:04:19 +1100 Subject: [PATCH 2/3] tidy --- crates/goose-cli/src/session/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index a324abae3d88..62a7829c56d4 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -848,7 +848,6 @@ impl CliSession { interactive: bool, cancel_token: CancellationToken, ) -> Result<()> { - // Cache the output format check to avoid repeated string comparisons in the hot loop let is_json_mode = self.output_format == "json"; let is_stream_json_mode = self.output_format == "stream-json"; From fa765798da46ae324c85982eb57127cf5f68aeba Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 23 Dec 2025 10:05:14 +1100 Subject: [PATCH 3/3] another silly comment --- crates/goose-cli/src/session/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 62a7829c56d4..a369b958cecb 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1146,7 +1146,6 @@ impl CliSession { }, }; - // Handle stream-json mode for notifications if is_stream_json_mode { emit_stream_event(&StreamEvent::Notification { extension_id: extension_id.clone(),