assistant: Update `SlashCommand` trait with streaming return type (#19652)

Marshall Bowers and Max created

This PR updates the `SlashCommand` trait to use a streaming return type.

This change is just at the trait layer. The goal here is to decouple
changing the trait's API while preserving behavior on either side.

The `SlashCommandOutput` type now has two methods for converting two and
from a stream to use in cases where we're not yet doing streaming.

On the `SlashCommand` implementer side, the implements can call
`to_event_stream` to produce a stream of events based off the
`SlashCommandOutput`.

On the slash command consumer side we use
`SlashCommandOutput::from_event_stream` to convert a stream of events
back into a `SlashCommandOutput`.

The `/file` slash command has been updated to emit `SlashCommandEvent`s
directly in order for it to work properly.

Release Notes:

- N/A

---------

Co-authored-by: Max <max@zed.dev>

Change summary

Cargo.lock                                                    |   2 
crates/assistant/src/context.rs                               |  18 
crates/assistant/src/context/context_tests.rs                 |   6 
crates/assistant/src/slash_command/auto_command.rs            |   3 
crates/assistant/src/slash_command/cargo_workspace_command.rs |   3 
crates/assistant/src/slash_command/context_server_command.rs  |   3 
crates/assistant/src/slash_command/default_command.rs         |   3 
crates/assistant/src/slash_command/delta_command.rs           |  31 
crates/assistant/src/slash_command/diagnostics_command.rs     |   6 
crates/assistant/src/slash_command/docs_command.rs            |   3 
crates/assistant/src/slash_command/fetch_command.rs           |   3 
crates/assistant/src/slash_command/file_command.rs            | 114 
crates/assistant/src/slash_command/now_command.rs             |   3 
crates/assistant/src/slash_command/project_command.rs         |   3 
crates/assistant/src/slash_command/prompt_command.rs          |   3 
crates/assistant/src/slash_command/search_command.rs          |   1 
crates/assistant/src/slash_command/symbols_command.rs         |   3 
crates/assistant/src/slash_command/tab_command.rs             |   2 
crates/assistant/src/slash_command/terminal_command.rs        |   3 
crates/assistant/src/slash_command/workflow_command.rs        |   3 
crates/assistant_slash_command/Cargo.toml                     |   6 
crates/assistant_slash_command/src/assistant_slash_command.rs | 379 ++++
crates/extension/src/extension_slash_command.rs               |   3 
23 files changed, 516 insertions(+), 88 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -453,9 +453,11 @@ dependencies = [
  "anyhow",
  "collections",
  "derive_more",
+ "futures 0.3.30",
  "gpui",
  "language",
  "parking_lot",
+ "pretty_assertions",
  "serde",
  "serde_json",
  "workspace",

crates/assistant/src/context.rs 🔗

@@ -7,7 +7,7 @@ use crate::{
 };
 use anyhow::{anyhow, Context as _, Result};
 use assistant_slash_command::{
-    SlashCommandOutputSection, SlashCommandRegistry, SlashCommandResult,
+    SlashCommandOutput, SlashCommandOutputSection, SlashCommandRegistry, SlashCommandResult,
 };
 use assistant_tool::ToolRegistry;
 use client::{self, proto, telemetry::Telemetry};
@@ -1688,19 +1688,13 @@ impl Context {
             let command_range = command_range.clone();
             async move {
                 let output = output.await;
+                let output = match output {
+                    Ok(output) => SlashCommandOutput::from_event_stream(output).await,
+                    Err(err) => Err(err),
+                };
                 this.update(&mut cx, |this, cx| match output {
                     Ok(mut output) => {
-                        // Ensure section ranges are valid.
-                        for section in &mut output.sections {
-                            section.range.start = section.range.start.min(output.text.len());
-                            section.range.end = section.range.end.min(output.text.len());
-                            while !output.text.is_char_boundary(section.range.start) {
-                                section.range.start -= 1;
-                            }
-                            while !output.text.is_char_boundary(section.range.end) {
-                                section.range.end += 1;
-                            }
-                        }
+                        output.ensure_valid_section_ranges();
 
                         // Ensure there is a newline after the last section.
                         if ensure_trailing_newline {

crates/assistant/src/context/context_tests.rs 🔗

@@ -1097,7 +1097,8 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std
                             text: output_text,
                             sections,
                             run_commands_in_text: false,
-                        })),
+                        }
+                        .to_event_stream())),
                         true,
                         false,
                         cx,
@@ -1421,6 +1422,7 @@ impl SlashCommand for FakeSlashCommand {
             text: format!("Executed fake command: {}", self.0),
             sections: vec![],
             run_commands_in_text: false,
-        }))
+        }
+        .to_event_stream()))
     }
 }

crates/assistant/src/slash_command/delta_command.rs 🔗

@@ -86,25 +86,28 @@ impl SlashCommand for DeltaSlashCommand {
                 .zip(file_command_new_outputs)
             {
                 if let Ok(new_output) = new_output {
-                    if let Some(file_command_range) = new_output.sections.first() {
-                        let new_text = &new_output.text[file_command_range.range.clone()];
-                        if old_text.chars().ne(new_text.chars()) {
-                            output.sections.extend(new_output.sections.into_iter().map(
-                                |section| SlashCommandOutputSection {
-                                    range: output.text.len() + section.range.start
-                                        ..output.text.len() + section.range.end,
-                                    icon: section.icon,
-                                    label: section.label,
-                                    metadata: section.metadata,
-                                },
-                            ));
-                            output.text.push_str(&new_output.text);
+                    if let Ok(new_output) = SlashCommandOutput::from_event_stream(new_output).await
+                    {
+                        if let Some(file_command_range) = new_output.sections.first() {
+                            let new_text = &new_output.text[file_command_range.range.clone()];
+                            if old_text.chars().ne(new_text.chars()) {
+                                output.sections.extend(new_output.sections.into_iter().map(
+                                    |section| SlashCommandOutputSection {
+                                        range: output.text.len() + section.range.start
+                                            ..output.text.len() + section.range.end,
+                                        icon: section.icon,
+                                        label: section.label,
+                                        metadata: section.metadata,
+                                    },
+                                ));
+                                output.text.push_str(&new_output.text);
+                            }
                         }
                     }
                 }
             }
 
-            Ok(output)
+            Ok(output.to_event_stream())
         })
     }
 }

crates/assistant/src/slash_command/diagnostics_command.rs 🔗

@@ -180,7 +180,11 @@ impl SlashCommand for DiagnosticsSlashCommand {
 
         let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx);
 
-        cx.spawn(move |_| async move { task.await?.ok_or_else(|| anyhow!("No diagnostics found")) })
+        cx.spawn(move |_| async move {
+            task.await?
+                .map(|output| output.to_event_stream())
+                .ok_or_else(|| anyhow!("No diagnostics found"))
+        })
     }
 }
 

crates/assistant/src/slash_command/file_command.rs 🔗

@@ -1,13 +1,15 @@
 use anyhow::{anyhow, Context as _, Result};
 use assistant_slash_command::{
-    AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandOutput,
-    SlashCommandOutputSection, SlashCommandResult,
+    AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent,
+    SlashCommandOutput, SlashCommandOutputSection, SlashCommandResult,
 };
+use futures::channel::mpsc;
 use fuzzy::PathMatch;
 use gpui::{AppContext, Model, Task, View, WeakView};
 use language::{BufferSnapshot, CodeLabel, HighlightId, LineEnding, LspAdapterDelegate};
 use project::{PathMatchCandidateSet, Project};
 use serde::{Deserialize, Serialize};
+use smol::stream::StreamExt;
 use std::{
     fmt::Write,
     ops::{Range, RangeInclusive},
@@ -221,11 +223,11 @@ fn collect_files(
         .map(|worktree| worktree.read(cx).snapshot())
         .collect::<Vec<_>>();
 
+    let (events_tx, events_rx) = mpsc::unbounded();
     cx.spawn(|mut cx| async move {
-        let mut output = SlashCommandOutput::default();
         for snapshot in snapshots {
             let worktree_id = snapshot.id();
-            let mut directory_stack: Vec<(Arc<Path>, String, usize)> = Vec::new();
+            let mut directory_stack: Vec<Arc<Path>> = Vec::new();
             let mut folded_directory_names_stack = Vec::new();
             let mut is_top_level_directory = true;
 
@@ -241,17 +243,19 @@ fn collect_files(
                     continue;
                 }
 
-                while let Some((dir, _, _)) = directory_stack.last() {
+                while let Some(dir) = directory_stack.last() {
                     if entry.path.starts_with(dir) {
                         break;
                     }
-                    let (_, entry_name, start) = directory_stack.pop().unwrap();
-                    output.sections.push(build_entry_output_section(
-                        start..output.text.len().saturating_sub(1),
-                        Some(&PathBuf::from(entry_name)),
-                        true,
-                        None,
-                    ));
+                    directory_stack.pop().unwrap();
+                    events_tx
+                        .unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))?;
+                    events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
+                        SlashCommandContent::Text {
+                            text: "\n".into(),
+                            run_commands_in_text: false,
+                        },
+                    )))?;
                 }
 
                 let filename = entry
@@ -283,23 +287,46 @@ fn collect_files(
                         continue;
                     }
                     let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
-                    let entry_start = output.text.len();
                     if prefix_paths.is_empty() {
-                        if is_top_level_directory {
-                            output
-                                .text
-                                .push_str(&path_including_worktree_name.to_string_lossy());
+                        let label = if is_top_level_directory {
                             is_top_level_directory = false;
+                            path_including_worktree_name.to_string_lossy().to_string()
                         } else {
-                            output.text.push_str(&filename);
-                        }
-                        directory_stack.push((entry.path.clone(), filename, entry_start));
+                            filename
+                        };
+                        events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
+                            icon: IconName::Folder,
+                            label: label.clone().into(),
+                            metadata: None,
+                        }))?;
+                        events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
+                            SlashCommandContent::Text {
+                                text: label,
+                                run_commands_in_text: false,
+                            },
+                        )))?;
+                        directory_stack.push(entry.path.clone());
                     } else {
                         let entry_name = format!("{}/{}", prefix_paths, &filename);
-                        output.text.push_str(&entry_name);
-                        directory_stack.push((entry.path.clone(), entry_name, entry_start));
+                        events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
+                            icon: IconName::Folder,
+                            label: entry_name.clone().into(),
+                            metadata: None,
+                        }))?;
+                        events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
+                            SlashCommandContent::Text {
+                                text: entry_name,
+                                run_commands_in_text: false,
+                            },
+                        )))?;
+                        directory_stack.push(entry.path.clone());
                     }
-                    output.text.push('\n');
+                    events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
+                        SlashCommandContent::Text {
+                            text: "\n".into(),
+                            run_commands_in_text: false,
+                        },
+                    )))?;
                 } else if entry.is_file() {
                     let Some(open_buffer_task) = project_handle
                         .update(&mut cx, |project, cx| {
@@ -310,6 +337,7 @@ fn collect_files(
                         continue;
                     };
                     if let Some(buffer) = open_buffer_task.await.log_err() {
+                        let mut output = SlashCommandOutput::default();
                         let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot())?;
                         append_buffer_to_output(
                             &snapshot,
@@ -317,32 +345,19 @@ fn collect_files(
                             &mut output,
                         )
                         .log_err();
+                        let mut buffer_events = output.to_event_stream();
+                        while let Some(event) = buffer_events.next().await {
+                            events_tx.unbounded_send(event)?;
+                        }
                     }
                 }
             }
 
-            while let Some((dir, entry, start)) = directory_stack.pop() {
-                if directory_stack.is_empty() {
-                    let mut root_path = PathBuf::new();
-                    root_path.push(snapshot.root_name());
-                    root_path.push(&dir);
-                    output.sections.push(build_entry_output_section(
-                        start..output.text.len(),
-                        Some(&root_path),
-                        true,
-                        None,
-                    ));
-                } else {
-                    output.sections.push(build_entry_output_section(
-                        start..output.text.len(),
-                        Some(&PathBuf::from(entry.as_str())),
-                        true,
-                        None,
-                    ));
-                }
+            while let Some(_) = directory_stack.pop() {
+                events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))?;
             }
         }
-        Ok(output)
+        Ok(events_rx.boxed())
     })
 }
 
@@ -528,8 +543,10 @@ pub fn append_buffer_to_output(
 
 #[cfg(test)]
 mod test {
+    use assistant_slash_command::SlashCommandOutput;
     use fs::FakeFs;
     use gpui::TestAppContext;
+    use pretty_assertions::assert_eq;
     use project::Project;
     use serde_json::json;
     use settings::SettingsStore;
@@ -577,6 +594,9 @@ mod test {
             .update(|cx| collect_files(project.clone(), &["root/dir".to_string()], cx))
             .await
             .unwrap();
+        let result_1 = SlashCommandOutput::from_event_stream(result_1)
+            .await
+            .unwrap();
 
         assert!(result_1.text.starts_with("root/dir"));
         // 4 files + 2 directories
@@ -586,6 +606,9 @@ mod test {
             .update(|cx| collect_files(project.clone(), &["root/dir/".to_string()], cx))
             .await
             .unwrap();
+        let result_2 = SlashCommandOutput::from_event_stream(result_2)
+            .await
+            .unwrap();
 
         assert_eq!(result_1, result_2);
 
@@ -593,6 +616,7 @@ mod test {
             .update(|cx| collect_files(project.clone(), &["root/dir*".to_string()], cx))
             .await
             .unwrap();
+        let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
 
         assert!(result.text.starts_with("root/dir"));
         // 5 files + 2 directories
@@ -639,6 +663,7 @@ mod test {
             .update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx))
             .await
             .unwrap();
+        let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
 
         // Sanity check
         assert!(result.text.starts_with("zed/assets/themes\n"));
@@ -700,6 +725,7 @@ mod test {
             .update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx))
             .await
             .unwrap();
+        let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
 
         assert!(result.text.starts_with("zed/assets/themes\n"));
         assert_eq!(result.sections[0].label, "zed/assets/themes/LICENSE");
@@ -720,6 +746,8 @@ mod test {
         assert_eq!(result.sections[6].label, "summercamp");
         assert_eq!(result.sections[7].label, "zed/assets/themes");
 
+        assert_eq!(result.text, "zed/assets/themes\n```zed/assets/themes/LICENSE\n1\n```\n\nsummercamp\n```zed/assets/themes/summercamp/LICENSE\n1\n```\n\nsubdir\n```zed/assets/themes/summercamp/subdir/LICENSE\n1\n```\n\nsubsubdir\n```zed/assets/themes/summercamp/subdir/subsubdir/LICENSE\n3\n```\n\n");
+
         // Ensure that the project lasts until after the last await
         drop(project);
     }

crates/assistant/src/slash_command/tab_command.rs 🔗

@@ -150,7 +150,7 @@ impl SlashCommand for TabSlashCommand {
             for (full_path, buffer, _) in tab_items_search.await? {
                 append_buffer_to_output(&buffer, full_path.as_deref(), &mut output).log_err();
             }
-            Ok(output)
+            Ok(output.to_event_stream())
         })
     }
 }

crates/assistant_slash_command/Cargo.toml 🔗

@@ -15,9 +15,15 @@ path = "src/assistant_slash_command.rs"
 anyhow.workspace = true
 collections.workspace = true
 derive_more.workspace = true
+futures.workspace = true
 gpui.workspace = true
 language.workspace = true
 parking_lot.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 workspace.workspace = true
+
+[dev-dependencies]
+gpui = { workspace = true, features = ["test-support"] }
+pretty_assertions.workspace = true
+workspace = { workspace = true, features = ["test-support"] }

crates/assistant_slash_command/src/assistant_slash_command.rs 🔗

@@ -1,6 +1,8 @@
 mod slash_command_registry;
 
 use anyhow::Result;
+use futures::stream::{self, BoxStream};
+use futures::StreamExt;
 use gpui::{AnyElement, AppContext, ElementId, SharedString, Task, WeakView, WindowContext};
 use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate, OffsetRangeExt};
 use serde::{Deserialize, Serialize};
@@ -56,7 +58,7 @@ pub struct ArgumentCompletion {
     pub replace_previous_arguments: bool,
 }
 
-pub type SlashCommandResult = Result<SlashCommandOutput>;
+pub type SlashCommandResult = Result<BoxStream<'static, Result<SlashCommandEvent>>>;
 
 pub trait SlashCommand: 'static + Send + Sync {
     fn name(&self) -> String;
@@ -98,13 +100,146 @@ pub type RenderFoldPlaceholder = Arc<
         + Fn(ElementId, Arc<dyn Fn(&mut WindowContext)>, &mut WindowContext) -> AnyElement,
 >;
 
-#[derive(Debug, Default, PartialEq)]
+#[derive(Debug, PartialEq, Eq)]
+pub enum SlashCommandContent {
+    Text {
+        text: String,
+        run_commands_in_text: bool,
+    },
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum SlashCommandEvent {
+    StartSection {
+        icon: IconName,
+        label: SharedString,
+        metadata: Option<serde_json::Value>,
+    },
+    Content(SlashCommandContent),
+    EndSection {
+        metadata: Option<serde_json::Value>,
+    },
+}
+
+#[derive(Debug, Default, PartialEq, Clone)]
 pub struct SlashCommandOutput {
     pub text: String,
     pub sections: Vec<SlashCommandOutputSection<usize>>,
     pub run_commands_in_text: bool,
 }
 
+impl SlashCommandOutput {
+    pub fn ensure_valid_section_ranges(&mut self) {
+        for section in &mut self.sections {
+            section.range.start = section.range.start.min(self.text.len());
+            section.range.end = section.range.end.min(self.text.len());
+            while !self.text.is_char_boundary(section.range.start) {
+                section.range.start -= 1;
+            }
+            while !self.text.is_char_boundary(section.range.end) {
+                section.range.end += 1;
+            }
+        }
+    }
+
+    /// Returns this [`SlashCommandOutput`] as a stream of [`SlashCommandEvent`]s.
+    pub fn to_event_stream(mut self) -> BoxStream<'static, Result<SlashCommandEvent>> {
+        self.ensure_valid_section_ranges();
+
+        let mut events = Vec::new();
+        let mut last_section_end = 0;
+
+        for section in self.sections {
+            if last_section_end < section.range.start {
+                events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
+                    text: self
+                        .text
+                        .get(last_section_end..section.range.start)
+                        .unwrap_or_default()
+                        .to_string(),
+                    run_commands_in_text: self.run_commands_in_text,
+                })));
+            }
+
+            events.push(Ok(SlashCommandEvent::StartSection {
+                icon: section.icon,
+                label: section.label,
+                metadata: section.metadata.clone(),
+            }));
+            events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
+                text: self
+                    .text
+                    .get(section.range.start..section.range.end)
+                    .unwrap_or_default()
+                    .to_string(),
+                run_commands_in_text: self.run_commands_in_text,
+            })));
+            events.push(Ok(SlashCommandEvent::EndSection {
+                metadata: section.metadata,
+            }));
+
+            last_section_end = section.range.end;
+        }
+
+        if last_section_end < self.text.len() {
+            events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
+                text: self.text[last_section_end..].to_string(),
+                run_commands_in_text: self.run_commands_in_text,
+            })));
+        }
+
+        stream::iter(events).boxed()
+    }
+
+    pub async fn from_event_stream(
+        mut events: BoxStream<'static, Result<SlashCommandEvent>>,
+    ) -> Result<SlashCommandOutput> {
+        let mut output = SlashCommandOutput::default();
+        let mut section_stack = Vec::new();
+
+        while let Some(event) = events.next().await {
+            match event? {
+                SlashCommandEvent::StartSection {
+                    icon,
+                    label,
+                    metadata,
+                } => {
+                    let start = output.text.len();
+                    section_stack.push(SlashCommandOutputSection {
+                        range: start..start,
+                        icon,
+                        label,
+                        metadata,
+                    });
+                }
+                SlashCommandEvent::Content(SlashCommandContent::Text {
+                    text,
+                    run_commands_in_text,
+                }) => {
+                    output.text.push_str(&text);
+                    output.run_commands_in_text = run_commands_in_text;
+
+                    if let Some(section) = section_stack.last_mut() {
+                        section.range.end = output.text.len();
+                    }
+                }
+                SlashCommandEvent::EndSection { metadata } => {
+                    if let Some(mut section) = section_stack.pop() {
+                        section.metadata = metadata;
+                        output.sections.push(section);
+                    }
+                }
+            }
+        }
+
+        while let Some(section) = section_stack.pop() {
+            output.sections.push(section);
+        }
+
+        Ok(output)
+    }
+}
+
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 pub struct SlashCommandOutputSection<T> {
     pub range: Range<T>,
@@ -118,3 +253,243 @@ impl SlashCommandOutputSection<language::Anchor> {
         self.range.start.is_valid(buffer) && !self.range.to_offset(buffer).is_empty()
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use pretty_assertions::assert_eq;
+    use serde_json::json;
+
+    use super::*;
+
+    #[gpui::test]
+    async fn test_slash_command_output_to_events_round_trip() {
+        // Test basic output consisting of a single section.
+        {
+            let text = "Hello, world!".to_string();
+            let range = 0..text.len();
+            let output = SlashCommandOutput {
+                text,
+                sections: vec![SlashCommandOutputSection {
+                    range,
+                    icon: IconName::Code,
+                    label: "Section 1".into(),
+                    metadata: None,
+                }],
+                run_commands_in_text: false,
+            };
+
+            let events = output.clone().to_event_stream().collect::<Vec<_>>().await;
+            let events = events
+                .into_iter()
+                .filter_map(|event| event.ok())
+                .collect::<Vec<_>>();
+
+            assert_eq!(
+                events,
+                vec![
+                    SlashCommandEvent::StartSection {
+                        icon: IconName::Code,
+                        label: "Section 1".into(),
+                        metadata: None
+                    },
+                    SlashCommandEvent::Content(SlashCommandContent::Text {
+                        text: "Hello, world!".into(),
+                        run_commands_in_text: false
+                    }),
+                    SlashCommandEvent::EndSection { metadata: None }
+                ]
+            );
+
+            let new_output =
+                SlashCommandOutput::from_event_stream(output.clone().to_event_stream())
+                    .await
+                    .unwrap();
+
+            assert_eq!(new_output, output);
+        }
+
+        // Test output where the sections do not comprise all of the text.
+        {
+            let text = "Apple\nCucumber\nBanana\n".to_string();
+            let output = SlashCommandOutput {
+                text,
+                sections: vec![
+                    SlashCommandOutputSection {
+                        range: 0..6,
+                        icon: IconName::Check,
+                        label: "Fruit".into(),
+                        metadata: None,
+                    },
+                    SlashCommandOutputSection {
+                        range: 15..22,
+                        icon: IconName::Check,
+                        label: "Fruit".into(),
+                        metadata: None,
+                    },
+                ],
+                run_commands_in_text: false,
+            };
+
+            let events = output.clone().to_event_stream().collect::<Vec<_>>().await;
+            let events = events
+                .into_iter()
+                .filter_map(|event| event.ok())
+                .collect::<Vec<_>>();
+
+            assert_eq!(
+                events,
+                vec![
+                    SlashCommandEvent::StartSection {
+                        icon: IconName::Check,
+                        label: "Fruit".into(),
+                        metadata: None
+                    },
+                    SlashCommandEvent::Content(SlashCommandContent::Text {
+                        text: "Apple\n".into(),
+                        run_commands_in_text: false
+                    }),
+                    SlashCommandEvent::EndSection { metadata: None },
+                    SlashCommandEvent::Content(SlashCommandContent::Text {
+                        text: "Cucumber\n".into(),
+                        run_commands_in_text: false
+                    }),
+                    SlashCommandEvent::StartSection {
+                        icon: IconName::Check,
+                        label: "Fruit".into(),
+                        metadata: None
+                    },
+                    SlashCommandEvent::Content(SlashCommandContent::Text {
+                        text: "Banana\n".into(),
+                        run_commands_in_text: false
+                    }),
+                    SlashCommandEvent::EndSection { metadata: None }
+                ]
+            );
+
+            let new_output =
+                SlashCommandOutput::from_event_stream(output.clone().to_event_stream())
+                    .await
+                    .unwrap();
+
+            assert_eq!(new_output, output);
+        }
+
+        // Test output consisting of multiple sections.
+        {
+            let text = "Line 1\nLine 2\nLine 3\nLine 4\n".to_string();
+            let output = SlashCommandOutput {
+                text,
+                sections: vec![
+                    SlashCommandOutputSection {
+                        range: 0..6,
+                        icon: IconName::FileCode,
+                        label: "Section 1".into(),
+                        metadata: Some(json!({ "a": true })),
+                    },
+                    SlashCommandOutputSection {
+                        range: 7..13,
+                        icon: IconName::FileDoc,
+                        label: "Section 2".into(),
+                        metadata: Some(json!({ "b": true })),
+                    },
+                    SlashCommandOutputSection {
+                        range: 14..20,
+                        icon: IconName::FileGit,
+                        label: "Section 3".into(),
+                        metadata: Some(json!({ "c": true })),
+                    },
+                    SlashCommandOutputSection {
+                        range: 21..27,
+                        icon: IconName::FileToml,
+                        label: "Section 4".into(),
+                        metadata: Some(json!({ "d": true })),
+                    },
+                ],
+                run_commands_in_text: false,
+            };
+
+            let events = output.clone().to_event_stream().collect::<Vec<_>>().await;
+            let events = events
+                .into_iter()
+                .filter_map(|event| event.ok())
+                .collect::<Vec<_>>();
+
+            assert_eq!(
+                events,
+                vec![
+                    SlashCommandEvent::StartSection {
+                        icon: IconName::FileCode,
+                        label: "Section 1".into(),
+                        metadata: Some(json!({ "a": true }))
+                    },
+                    SlashCommandEvent::Content(SlashCommandContent::Text {
+                        text: "Line 1".into(),
+                        run_commands_in_text: false
+                    }),
+                    SlashCommandEvent::EndSection {
+                        metadata: Some(json!({ "a": true }))
+                    },
+                    SlashCommandEvent::Content(SlashCommandContent::Text {
+                        text: "\n".into(),
+                        run_commands_in_text: false
+                    }),
+                    SlashCommandEvent::StartSection {
+                        icon: IconName::FileDoc,
+                        label: "Section 2".into(),
+                        metadata: Some(json!({ "b": true }))
+                    },
+                    SlashCommandEvent::Content(SlashCommandContent::Text {
+                        text: "Line 2".into(),
+                        run_commands_in_text: false
+                    }),
+                    SlashCommandEvent::EndSection {
+                        metadata: Some(json!({ "b": true }))
+                    },
+                    SlashCommandEvent::Content(SlashCommandContent::Text {
+                        text: "\n".into(),
+                        run_commands_in_text: false
+                    }),
+                    SlashCommandEvent::StartSection {
+                        icon: IconName::FileGit,
+                        label: "Section 3".into(),
+                        metadata: Some(json!({ "c": true }))
+                    },
+                    SlashCommandEvent::Content(SlashCommandContent::Text {
+                        text: "Line 3".into(),
+                        run_commands_in_text: false
+                    }),
+                    SlashCommandEvent::EndSection {
+                        metadata: Some(json!({ "c": true }))
+                    },
+                    SlashCommandEvent::Content(SlashCommandContent::Text {
+                        text: "\n".into(),
+                        run_commands_in_text: false
+                    }),
+                    SlashCommandEvent::StartSection {
+                        icon: IconName::FileToml,
+                        label: "Section 4".into(),
+                        metadata: Some(json!({ "d": true }))
+                    },
+                    SlashCommandEvent::Content(SlashCommandContent::Text {
+                        text: "Line 4".into(),
+                        run_commands_in_text: false
+                    }),
+                    SlashCommandEvent::EndSection {
+                        metadata: Some(json!({ "d": true }))
+                    },
+                    SlashCommandEvent::Content(SlashCommandContent::Text {
+                        text: "\n".into(),
+                        run_commands_in_text: false
+                    }),
+                ]
+            );
+
+            let new_output =
+                SlashCommandOutput::from_event_stream(output.clone().to_event_stream())
+                    .await
+                    .unwrap();
+
+            assert_eq!(new_output, output);
+        }
+    }
+}