Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions crates/goose-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -753,13 +753,13 @@ enum Command {
)]
additional_sub_recipes: Vec<String>,

/// Output format (text, json)
/// Output format (text, json, stream-json)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DOsinga I guess at this level they are distinct - and this is consistent with what others like codex do

#[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,

Expand Down
129 changes: 104 additions & 25 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i32>,
},
}

#[derive(Serialize, Debug)]
#[serde(rename_all = "snake_case")]
enum NotificationData {
Log {
message: String,
},
Progress {
progress: f64,
total: Option<f64>,
message: Option<String>,
},
}

pub enum RunMode {
Normal,
Plan,
Expand Down Expand Up @@ -812,8 +848,15 @@ 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";

// 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(),
Expand Down Expand Up @@ -1027,13 +1070,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 = &notification.params.data;
Expand Down Expand Up @@ -1101,9 +1146,14 @@ impl CliSession {
},
};

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 {
Expand All @@ -1125,7 +1175,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();
}
Expand All @@ -1145,12 +1194,24 @@ impl CliSession {
let text = notification.params.message.as_deref();
let total = notification.params.total;
let token = &notification.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,
);
}
},
_ => (),
}
Expand All @@ -1159,32 +1220,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::<goose::providers::errors::ProviderError>()
.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\
Expand All @@ -1207,7 +1280,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 {
Expand All @@ -1226,6 +1299,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!();
}
Expand Down
Loading