Week 12 of 2026

Development log of Tad Better Behavior

4 items
  1. Improve the readme a bit
  2. Add "TBB" to Harper dictionary
  3. WIP: implement storing snippets in a report
  4. Implement Text observation

Improve the readme a bit

On by Tad Lispy

index 8d5f4a0..969b745 100644
--- a/README.md
+++ b/README.md
@@ -5,32 +5,32 @@
=A BDD test runner inspired by Gauge, but better.
=
=  * No magic
-  * Flexibility
+  * Simple flexibility
=  * Cross-platform
=  * Batteries included, but replaceable
=
=
-## No magic
+## No Magic
=
-Test scenarios are evaluated by interpreters, which are normal programs that read JSON on `stdin` and write JSON to `stdout`. User can implement them in any language or framework they like, using any tools they are comfortable with (editors, language servers, libraries, etc).
+Test scenarios are evaluated by interpreters, which are normal programs that read JSON on `stdin` and write JSON to `stdout`. User can implement them in any language or framework they like, using any tools they are comfortable with (editors, language servers, libraries, etc.).
=
=
-## Flexibility
+## Simple Flexibility
=
-Different test suites can use different interpreters, which may be implemented using different languages. If your computer can run it, `tbb` can use it as an interpreter.
=
-When executing a step, the interpreter will get more data from the markdown fragment which defined the step
+You define scenarios and steps to verify them. Steps are executed by programs you write. Different test suites can use different interpreters, which may be implemented using different languages. If your computer can run it, `tbb` can use it as an interpreter.
+
+When executing a step, the interpreter will get a lot of data from the Markdown fragment which defined the step, like:
=
=  - tables
=  - lists
=  - code blocks
-
-and even the original markdown fragment itself.
+  - and even the original Markdown fragment itself.
=
=
-## Cross platform
+## Unix Powered
=
-Support for Linux, BSD, OS X and Web (WASM).
+Support for Linux, BSD, and OS X and uses standard Unix conventions: child processes, input, and output streams, etc. 
=
=
=## Batteries included, but replaceable

Add "TBB" to Harper dictionary

On by Tad Lispy

Harper is a spell and grammar checker I am using.

new file mode 100644
index 0000000..782251e
--- /dev/null
+++ b/.harper-dictionary.txt
@@ -0,0 +1 @@
+TBB

WIP: implement storing snippets in a report

On by Tad Lispy

After working with Jewiet on her project it became clear that error messages are often not enough to figure out what's wrong with a system under test. We need more data, especially from preceding steps. Observations like logging, attachments, audio and video clips.

For we designed the observations API. It will allow to attach various pieces of data to step reports.

This commit introduces a first kind of observation - snippet of code.

index 4160666..c30568f 100644
--- a/samples/basic.md
+++ b/samples/basic.md
@@ -58,6 +58,12 @@ This
=  * There are `3` `r`s in the word `strawberry`
=  * The following table maps words to their lengths
=  
+    | word   | length |
+    |--------|--------|
+    | cat    | 3      |
+    | stork  | 5      |
+    | snake  | 5      |
+
=    | word   | length |
=    |--------|--------|
=    | cat    | 3      |
index 45dfd6f..01fc945 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -4,7 +4,7 @@ import json
=import unittest
=
=import spec.tbb as tbb
-from spec.tbb import step, log
+from spec.tbb import step, log, send_snippet
=
=
=# Nice assertions with helpful error messages
@@ -12,7 +12,7 @@ tester = unittest.TestCase()
=
=@step("Add {0} and {1} to get {2}")
=def add_and_verify(a: float, b: float, expected: float, **kwargs):
-    log.debug(f"{ a } + { b } = { expected }?")
+    send_snippet("text", f"{ a } + { b } = { expected }?")
=
=    tester.assertEqual(expected, a + b)
=
@@ -53,8 +53,11 @@ def step_implementation_06(word: str, reverse: str, **kwargs):
=@step("The following table maps words to their lengths")
=def step_implementation_07(**kwargs):
=    for table in kwargs["tables"]:
+        send_snippet("text", f"Received a table with {len(table)} x {len(table[0])} cells")
+        
=        # Skip the first row - it's a heading
=        for [word, length] in table[1:]:
+            send_snippet("text", f"Is '{word}' {length} long?")
=            actual_length = len(word)
=            tester.assertEqual(actual_length, int(length), f"the length of {word=}")
=
index b7bf5de..76eb920 100644
--- a/spec/tbb.py
+++ b/spec/tbb.py
@@ -185,6 +185,15 @@ def ready():
=            break
=
=
+def send_snippet(language, content, meta = ""):
+    send({
+        "type": "Snippet",
+        "language": language,
+        "content": content,
+        "meta": meta
+    })
+    
+
=# TODO: Docstring and test cases for `get_at`. See `self-check` for example use.
=def get_at(collection, path: [str]):
=    value = collection
index d88b6e0..0906f9c 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -154,8 +154,9 @@ impl<'a> ScenarioReport<'a> {
=            };
=        }
=
-        for &mut StepReport {
+        'steps_loop: for &mut StepReport {
=            step,
+            ref mut observations,
=            ref mut status,
=        } in self.steps.iter_mut()
=        {
@@ -167,30 +168,44 @@ impl<'a> ScenarioReport<'a> {
=                },
=            )?;
=
-            match Self::receive(&mut reader)? {
-                InterpreterMessage::Success => {
-                    log::debug!("Step executed successfully: {step:#?}");
-                    *status = StepStatus::Ok;
-                }
-                InterpreterMessage::Failure { reason, hint } => {
-                    log::debug!("Step failed:\n\n {step:#?} \n\n {reason:#?}");
-                    *status = StepStatus::Failed { reason, hint };
-
-                    // Do not execute subsequent steps.
-                    //
-                    // A scenario is a unit of testing. Later steps are expected
-                    // to depend on previous ones. If a step fails, continuing
-                    // may lead to a mess.
-                    //
-                    // Maybe this can be configured via front-matter?
-                    break;
-                }
-                unexpected => {
-                    return Err(anyhow!(
-                        "unexpected message received from the interpreter: {unexpected:#?}"
-                    ));
-                }
-            };
+            loop {
+                match Self::receive(&mut reader)? {
+                    InterpreterMessage::Snippet {
+                        language,
+                        meta,
+                        content,
+                    } => {
+                        observations.push(Observation::Snippet {
+                            language,
+                            meta,
+                            content,
+                        });
+                    }
+                    InterpreterMessage::Success => {
+                        log::debug!("Step executed successfully: {step:#?}");
+                        *status = StepStatus::Ok;
+                        break; // Proceed with the next step
+                    }
+                    InterpreterMessage::Failure { reason, hint } => {
+                        log::debug!("Step failed:\n\n {step:#?} \n\n {reason:#?}");
+                        *status = StepStatus::Failed { reason, hint };
+
+                        // Do not execute subsequent steps.
+                        //
+                        // A scenario is a unit of testing. Later steps are expected
+                        // to depend on previous ones. If a step fails, continuing
+                        // may lead to a mess.
+                        //
+                        // Maybe this can be configured via front-matter?
+                        break 'steps_loop;
+                    }
+                    unexpected => {
+                        return Err(anyhow!(
+                            "unexpected message received from the interpreter: {unexpected:#?}"
+                        ));
+                    }
+                };
+            }
=        }
=
=        drop(writer);
@@ -259,6 +274,32 @@ impl<'a> ScenarioReport<'a> {
=pub struct StepReport<'a> {
=    step: &'a Step,
=    status: StepStatus,
+    observations: Vec<Observation>,
+}
+
+#[derive(Debug, Serialize)]
+pub enum Observation {
+    Snippet {
+        language: String,
+        meta: String,
+        content: String,
+    },
+}
+
+impl Display for Observation {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Observation::Snippet {
+                language,
+                meta,
+                content,
+            } => {
+                writeln!(f, "``` {language} {meta}")?;
+                writeln!(f, "{content}")?;
+                writeln!(f, "```")
+            }
+        }
+    }
=}
=
=#[derive(Debug, Eq, PartialEq, Serialize)]
@@ -348,7 +389,15 @@ impl Display for EvaluationReport<'_> {
=                    writeln!(f, "{sigils}")?;
=                    writeln!(f, "{message}")?;
=                } else {
-                    for (index, StepReport { step, status }) in steps.iter().enumerate() {
+                    for (
+                        index,
+                        StepReport {
+                            step,
+                            status,
+                            observations,
+                        },
+                    ) in steps.iter().enumerate()
+                    {
=                        let sigil = match status {
=                            StepStatus::Ok => "⊞".to_string().bold().green(),
=                            StepStatus::Failed { .. } => "⊠".to_string().bold().red(),
@@ -376,6 +425,10 @@ impl Display for EvaluationReport<'_> {
=                            indentation = "".indent(4),
=                        )?;
=
+                        for observation in observations {
+                            writeln!(f, "\n{}", observation.to_string().indent(6))?;
+                        }
+
=                        if let StepStatus::Failed { reason, hint } = status {
=                            writeln!(f, "\n{}\n", reason.indent(6).red())?;
=                            if let Some(hint) = hint {
@@ -409,6 +462,7 @@ impl<'a> From<&'a Step> for StepReport<'a> {
=        Self {
=            step,
=            status: StepStatus::NotEvaluated,
+            observations: Vec::default(),
=        }
=    }
=}
@@ -420,6 +474,11 @@ pub enum InterpreterMessage {
=    InterpreterState {
=        ready: bool,
=    },
+    Snippet {
+        language: String,
+        meta: String,
+        content: String,
+    },
=    Success,
=    Failure {
=        reason: String,

Implement Text observation

On by Tad Lispy

It's similar to Snippet, but simpler - doesn't have a language and meta, just content. In the report it's rendered as Markdown block quote.

To make this rendering work I implemented the prefix_with method in the Indentable trait. Under the hood indent is now using this new method to prefix it's subject with spaces.

index c30568f..b5b548c 100644
--- a/samples/basic.md
+++ b/samples/basic.md
@@ -56,14 +56,10 @@ This
=    ```
=
=  * There are `3` `r`s in the word `strawberry`
-  * The following table maps words to their lengths
+  * The following tables map words to their lengths
+  
+    This step demonstrates the use of tables. They can be useful to run tests on multiple samples.
=  
-    | word   | length |
-    |--------|--------|
-    | cat    | 3      |
-    | stork  | 5      |
-    | snake  | 5      |
-
=    | word   | length |
=    |--------|--------|
=    | cat    | 3      |
@@ -71,7 +67,7 @@ This
=    | rabbit | 6      |
=    | snake  | 5      |
=    | minx   | 4      |
-
+    
=    Any table within a step will be passed to the interpreter as a 2d array, like so:
=    
=    ``` json
@@ -86,12 +82,19 @@ This
=    ```
=
=    Note that heading row is not treated in any special way, and all cells are strings. It's up to an interpreter to deal with it.
-   
+
+    Just like with code blocks, you can have multiple tables.
+
+    | word    | length |
+    |---------|--------|
+    | rust    | 4      |
+    | clojure | 7      |
+    | elm     | 3      |
=   
=  * The reverse of `abc` is `cba`
=  * The reverse of `CIA` is `KGB`
=  
-    This step is intentionally wrong to allow demonstrate that TBB will not proceed with following steps.
+    This step is intentionally wrong to allow demonstrate that TBB will not proceed with following steps. The report from a failing scenario should also display more diagnostic information from each step.
=    
=  * There are `2` `o`s in the word `boost`
=
@@ -101,4 +104,4 @@ This
=tags: [math]
=```
=
-There are no steps in this scenario. It's ok. Sometimes it's easy to start by describing the behavior in prose, and write formal steps to verify it later.  A scenario like this will be listed, but won't be evaluated.
+There are no steps in this scenario. It's ok. Sometimes it's easy to start by describing the behavior in prose, and write formal steps to verify it later.  A scenario like this will be listed, but won't be evaluated and won't have any effect on the result.
index 01fc945..75f73ef 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -4,7 +4,7 @@ import json
=import unittest
=
=import spec.tbb as tbb
-from spec.tbb import step, log, send_snippet
+from spec.tbb import step, log, send_snippet, send_text
=
=
=# Nice assertions with helpful error messages
@@ -12,19 +12,19 @@ tester = unittest.TestCase()
=
=@step("Add {0} and {1} to get {2}")
=def add_and_verify(a: float, b: float, expected: float, **kwargs):
-    send_snippet("text", f"{ a } + { b } = { expected }?")
+    send_text(f"Is { a } + { b } = { expected }? I think it's {a + b}")
=
=    tester.assertEqual(expected, a + b)
=
=@step("Divide {0} by {1} to get {2}")
=def divide_and_verify(a: float, b: float, expected: float, **kwargs):
-    log.debug(f"{ a } / { b } = { expected }?")
+    send_text(f"{ a } / { b } = { expected }?")
=    
=    tester.assertAlmostEqual (expected, a / b)
=
=@step("The word {0} has {1} characters")
=def verify_characters_count(word, expected_length: int, **kwargs):
-    log.debug(f"Is '{word}' of length {expected_length}?")
+    send_text(f"Is '{word}' of length {expected_length}?")
=    actual_length = len(word)
=
=    tester.assertEqual(expected_length, actual_length)
@@ -32,12 +32,13 @@ def verify_characters_count(word, expected_length: int, **kwargs):
=@step("There are {0} properties in the following JSON")
=def count_json_properties(expected_count: int, **kwargs):
=    code = kwargs['code_blocks'][0]['value']
-    log.debug(f"Are there {expected_count} properties in the following?\n{code}")
+    send_text(f"Are there {expected_count} properties in the following?\n{code}")
=    data = json.loads(code)
=    tester.assertEqual(expected_count, len(data))
=
=@step("Subtract {0} from {1} to get {2}")
=def step_implementation_04(a: float, b: float, expected: float, **kwargs):
+    send_text(f"Is { a } - { b } = { expected }? I think it's {a - b}")
=    tester.assertAlmostEqual(expected, b - a) 
=
=@step("There are {0} {1}s in the word {2}")
@@ -48,18 +49,20 @@ def step_implementation_05(count: int, letter: str, word: str, **kwargs):
=@step("The reverse of {0} is {1}")
=def step_implementation_06(word: str, reverse: str, **kwargs):
=    actual_reverse = word[::-1]
+    send_text(f"Is '{reverse}' the reverse of '{word}'? I think the reverse is '{actual_reverse}'.")
=    tester.assertEqual(reverse, actual_reverse)
=
-@step("The following table maps words to their lengths")
+@step("The following tables map words to their lengths")
=def step_implementation_07(**kwargs):
=    for table in kwargs["tables"]:
-        send_snippet("text", f"Received a table with {len(table)} x {len(table[0])} cells")
+        send_text(f"Received a table with {len(table)} x {len(table[0])} cells")
+        send_snippet("json", json.dumps(table, indent=2))
=        
=        # Skip the first row - it's a heading
=        for [word, length] in table[1:]:
-            send_snippet("text", f"Is '{word}' {length} long?")
=            actual_length = len(word)
+            send_text(f"Is '{word}' {length} characters long? I think it's {actual_length}.")
=            tester.assertEqual(actual_length, int(length), f"the length of {word=}")
=
-
+# After you are done with the setup, call the `ready` function.
=tbb.ready()
index 76eb920..74e1b01 100644
--- a/spec/tbb.py
+++ b/spec/tbb.py
@@ -185,7 +185,17 @@ def ready():
=            break
=
=
+def send_text(content):
+    """ Send a fragment of text to be displayed in a TBB report
+    """
+    send({
+        "type": "Text",
+        "content": content,
+    })
+
=def send_snippet(language, content, meta = ""):
+    """ Send a snippet of code to be displayed in a TBB report
+    """
=    send({
=        "type": "Snippet",
=        "language": language,
index d2c897c..7b4016f 100644
--- a/src/indentable.rs
+++ b/src/indentable.rs
@@ -1,15 +1,19 @@
=pub trait Indentable {
=    fn indent(self, width: usize) -> String;
+    fn prefix_with(self, prefix: &str) -> String;
=}
=
=impl Indentable for &str {
=    fn indent(self, width: usize) -> String {
=        let spaces = " ".repeat(width);
+        self.prefix_with(&spaces)
+    }
+    fn prefix_with(self, prefix: &str) -> String {
=        self.replace("\r\n", "\n")
=            .split("\n")
-            .map(|line| spaces.clone() + line)
+            .map(|line| format!("{prefix}{line}"))
=            .reduce(|accu, line| accu + "\n" + &line)
-            .unwrap_or(spaces)
+            .unwrap_or(prefix.to_string())
=    }
=}
=
@@ -17,105 +21,205 @@ impl Indentable for &str {
=mod tests {
=    use super::*;
=
-    #[test]
-    fn test_single_line() {
-        let input = "hello";
-        let expected = "    hello";
-        // 4 spaces
-
-        assert_eq!(input.indent(4), expected);
-    }
-
-    #[test]
-    fn test_single_line_2() {
-        let input = "hello";
-        let expected = "  hello";
-        // 2 spaces
-
-        assert_eq!(input.indent(2), expected);
-    }
-
-    #[test]
-    fn test_multiple_lines() {
-        let input = "hello\nworld";
-        let expected = "  hello\n  world";
-
-        assert_eq!(input.indent(2), expected);
+    mod prefix_with {
+        use super::*;
+
+        #[test]
+        fn test_single_line() {
+            let input = "hello";
+            let prefix = "*";
+            let expected = "*hello";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_single_line_with_whitespace() {
+            let input = "hello";
+            let prefix = "  !  ";
+            let expected = "  !  hello";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_multiple_lines() {
+            let input = "hello\nworld";
+            let prefix = "> ";
+            let expected = "> hello\n> world";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_multiple_lines_with_cariege_return() {
+            let input = "hello\r\nworld\r\nand\r\nmoon";
+            let prefix = "# ";
+            let expected = "# hello\n# world\n# and\n# moon";
+            // \r\n will be replaced with \n
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_empty_string() {
+            let input = "";
+            let prefix = "!!! ";
+            let expected = "!!! ";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_leading_line() {
+            let input = "\nhello";
+            let prefix = "Yo ";
+            let expected = "Yo \nYo hello";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_trailing_newline() {
+            let input = "hello\n";
+            let prefix = "Hey ";
+            let expected = "Hey hello\nHey ";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_multiple_empty_lines() {
+            let input = "\n\n\n";
+            let prefix = "Go!";
+            let expected = "Go!\nGo!\nGo!\nGo!";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_whitespace_in_content() {
+            let input = "  leading spaces  \n   trailing spaces ";
+            let prefix = "|";
+            let expected = "|  leading spaces  \n|   trailing spaces ";
+            // leading and trailing spaces will be preserved, so:
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
+
+        #[test]
+        fn test_no_newline_in_string() {
+            let input = "abc";
+            let prefix = "+";
+            let expected = "+abc";
+
+            assert_eq!(input.prefix_with(prefix), expected);
+        }
=    }
=
-    #[test]
-    fn test_multiple_lines_with_cariege_return() {
-        let input = "hello\r\nworld\r\nand\r\nmoon";
-        let expected = "  hello\n  world\n  and\n  moon";
-        // \r\n will be replaced with \n
-
-        assert_eq!(input.indent(2), expected);
-    }
-
-    #[test]
-    fn test_empty_string() {
-        let input = "";
-        let expected = "    ";
-        // 4 spaces
-
-        assert_eq!(input.indent(4), expected);
-    }
-
-    #[test]
-    fn test_leading_line() {
-        let input = "\nhello";
-        let expected = "    \n    hello";
-        // 4 spaces
-
-        assert_eq!(input.indent(4), expected);
-    }
-
-    #[test]
-    fn test_trailing_newline() {
-        let input = "hello\n";
-        let expected = "    hello\n    ";
-
-        assert_eq!(input.indent(4), expected);
-    }
-
-    #[test]
-    fn test_multiple_empty_lines() {
-        let input = "\n\n\n";
-        let expected = "    \n    \n    \n    ";
-        // four lines each containing four spaces
-
-        assert_eq!(input.indent(4), expected);
-    }
-
-    #[test]
-    fn test_whitespace_in_content() {
-        let input = "  leading spaces  \n   trailing spaces ";
-        let expected = "      leading spaces  \n       trailing spaces ";
-        // leading and trailing spaces will be preserved, so:
-        // line 1: 4 (indentation) + 2 (original) leading spaces
-        //         2 trailing spaces untouched
-        // line 2: 4 + 3 leading spaces
-        //         1 trailing space
-        //
-
-        assert_eq!(input.indent(4), expected);
-    }
-
-    #[test]
-    fn test_large_indent() {
-        let input = "a";
-        let expected = "                                        a";
-        // 40 spaces
-
-        assert_eq!(input.indent(40), expected);
-    }
-
-    #[test]
-    fn test_no_newline_in_string() {
-        let input = "abc";
-        let expected = "    abc";
-
-        assert_eq!(input.indent(4), expected);
+    mod indent {
+        use super::*;
+
+        #[test]
+        fn test_single_line() {
+            let input = "hello";
+            let expected = "    hello";
+            // 4 spaces
+
+            assert_eq!(input.indent(4), expected);
+        }
+
+        #[test]
+        fn test_single_line_2() {
+            let input = "hello";
+            let expected = "  hello";
+            // 2 spaces
+
+            assert_eq!(input.indent(2), expected);
+        }
+
+        #[test]
+        fn test_multiple_lines() {
+            let input = "hello\nworld";
+            let expected = "  hello\n  world";
+
+            assert_eq!(input.indent(2), expected);
+        }
+
+        #[test]
+        fn test_multiple_lines_with_cariege_return() {
+            let input = "hello\r\nworld\r\nand\r\nmoon";
+            let expected = "  hello\n  world\n  and\n  moon";
+            // \r\n will be replaced with \n
+
+            assert_eq!(input.indent(2), expected);
+        }
+
+        #[test]
+        fn test_empty_string() {
+            let input = "";
+            let expected = "    ";
+            // 4 spaces
+
+            assert_eq!(input.indent(4), expected);
+        }
+
+        #[test]
+        fn test_leading_line() {
+            let input = "\nhello";
+            let expected = "    \n    hello";
+            // 4 spaces
+
+            assert_eq!(input.indent(4), expected);
+        }
+
+        #[test]
+        fn test_trailing_newline() {
+            let input = "hello\n";
+            let expected = "    hello\n    ";
+
+            assert_eq!(input.indent(4), expected);
+        }
+
+        #[test]
+        fn test_multiple_empty_lines() {
+            let input = "\n\n\n";
+            let expected = "    \n    \n    \n    ";
+            // four lines each containing four spaces
+
+            assert_eq!(input.indent(4), expected);
+        }
+
+        #[test]
+        fn test_whitespace_in_content() {
+            let input = "  leading spaces  \n   trailing spaces ";
+            let expected = "      leading spaces  \n       trailing spaces ";
+            // leading and trailing spaces will be preserved, so:
+            // line 1: 4 (indentation) + 2 (original) leading spaces
+            //         2 trailing spaces untouched
+            // line 2: 4 + 3 leading spaces
+            //         1 trailing space
+            //
+
+            assert_eq!(input.indent(4), expected);
+        }
+
+        #[test]
+        fn test_large_indent() {
+            let input = "a";
+            let expected = "                                        a";
+            // 40 spaces
+
+            assert_eq!(input.indent(40), expected);
+        }
+
+        #[test]
+        fn test_no_newline_in_string() {
+            let input = "abc";
+            let expected = "    abc";
+
+            assert_eq!(input.indent(4), expected);
+        }
=    }
=}
=
index 0906f9c..3ae8227 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -170,6 +170,9 @@ impl<'a> ScenarioReport<'a> {
=
=            loop {
=                match Self::receive(&mut reader)? {
+                    InterpreterMessage::Text { content } => {
+                        observations.push(Observation::Text { content })
+                    }
=                    InterpreterMessage::Snippet {
=                        language,
=                        meta,
@@ -199,7 +202,7 @@ impl<'a> ScenarioReport<'a> {
=                        // Maybe this can be configured via front-matter?
=                        break 'steps_loop;
=                    }
-                    unexpected => {
+                    unexpected @ InterpreterMessage::InterpreterState { .. } => {
=                        return Err(anyhow!(
=                            "unexpected message received from the interpreter: {unexpected:#?}"
=                        ));
@@ -279,11 +282,19 @@ pub struct StepReport<'a> {
=
=#[derive(Debug, Serialize)]
=pub enum Observation {
+    Text {
+        content: String,
+    },
=    Snippet {
=        language: String,
=        meta: String,
=        content: String,
=    },
+    // Image,
+    // Audio,
+    // Video,
+    // Attachment,
+    // Link,
=}
=
=impl Display for Observation {
@@ -298,6 +309,9 @@ impl Display for Observation {
=                writeln!(f, "{content}")?;
=                writeln!(f, "```")
=            }
+            Observation::Text { content } => {
+                writeln!(f, "{}", content.prefix_with("> "))
+            }
=        }
=    }
=}
@@ -474,6 +488,9 @@ pub enum InterpreterMessage {
=    InterpreterState {
=        ready: bool,
=    },
+    Text {
+        content: String,
+    },
=    Snippet {
=        language: String,
=        meta: String,