From d0badcbe2da915fdc4e825f023034fdd293a7be3 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 6 May 2026 17:12:21 -0500 Subject: [PATCH 1/4] fix: emit tag boundaries during streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming loop now emits `\n` before the first thinking chunk and `\n\n\n` on transition to non-thinking content (or at end of stream), giving consumers well-formed output. For `content="text"` mode, tags are yielded as string chunks so concatenated output is properly delimited. For `content="all"` mode, behavior is unchanged — typed ContentThinking objects are yielded. Also removes the synthetic "\n\n" separator from the OpenAI provider's reasoning_summary_text.done event since the thinking→text transition now provides the visual break. Companion to tidyverse/ellmer#975. --- chatlas/_chat.py | 46 +++++++- chatlas/_provider_openai.py | 6 +- tests/test_stream_thinking.py | 212 ++++++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 tests/test_stream_thinking.py diff --git a/chatlas/_chat.py b/chatlas/_chat.py index fec8997f..16c196c2 100644 --- a/chatlas/_chat.py +++ b/chatlas/_chat.py @@ -2671,20 +2671,37 @@ def emit(text: str | Content): ) result = None + inside_thinking = False + for chunk in response: content = self.provider.stream_content(chunk) if content is not None: text = content_text(content) if text: + is_thinking = isinstance(content, ContentThinking) + if is_thinking and not inside_thinking: + emit("\n") + if content_mode == "text": + yield "\n" + inside_thinking = True + elif not is_thinking and inside_thinking: + emit("\n\n\n") + if content_mode == "text": + yield "\n\n\n" + inside_thinking = False + emit(text) - if content_mode == "all" and isinstance( - content, ContentThinking - ): + if content_mode == "all" and is_thinking: yield content else: yield text result = self.provider.stream_merge_chunks(result, chunk) + if inside_thinking: + emit("\n\n\n") + if content_mode == "text": + yield "\n\n\n" + turn = self.provider.stream_turn( result, has_data_model=data_model is not None, @@ -2777,20 +2794,37 @@ def emit(text: str | Content): ) result = None + inside_thinking = False + async for chunk in response: content = self.provider.stream_content(chunk) if content is not None: text = content_text(content) if text: + is_thinking = isinstance(content, ContentThinking) + if is_thinking and not inside_thinking: + emit("\n") + if content_mode == "text": + yield "\n" + inside_thinking = True + elif not is_thinking and inside_thinking: + emit("\n\n\n") + if content_mode == "text": + yield "\n\n\n" + inside_thinking = False + emit(text) - if content_mode == "all" and isinstance( - content, ContentThinking - ): + if content_mode == "all" and is_thinking: yield content else: yield text result = self.provider.stream_merge_chunks(result, chunk) + if inside_thinking: + emit("\n\n\n") + if content_mode == "text": + yield "\n\n\n" + turn = self.provider.stream_turn( result, has_data_model=data_model is not None, diff --git a/chatlas/_provider_openai.py b/chatlas/_provider_openai.py index 6f4f1292..15adc33a 100644 --- a/chatlas/_provider_openai.py +++ b/chatlas/_provider_openai.py @@ -300,9 +300,9 @@ def stream_content(self, chunk) -> Optional[Content]: # https://platform.openai.com/docs/api-reference/responses-streaming/response/reasoning_summary_text/delta return ContentThinking(thinking=chunk.delta) if chunk.type == "response.reasoning_summary_text.done": - # Separator between reasoning summary and response text - # https://platform.openai.com/docs/api-reference/responses-streaming/response/reasoning_summary_text/done - return ContentText.model_construct(text="\n\n") + # The thinking→text transition in _submit_turns already emits + # "\n\n\n" which provides the visual separator. + return None return None def stream_merge_chunks(self, completion, chunk): diff --git a/tests/test_stream_thinking.py b/tests/test_stream_thinking.py new file mode 100644 index 00000000..21156e9d --- /dev/null +++ b/tests/test_stream_thinking.py @@ -0,0 +1,212 @@ +"""Tests for streaming thinking tag boundary emission.""" + +from collections.abc import Sequence +from typing import Optional + +import pytest +from chatlas import Chat +from chatlas._content import Content, ContentText, ContentThinking +from chatlas._provider import Provider +from chatlas._turn import AssistantTurn + + +class FakeChunk: + """A fake chunk that carries a Content object.""" + + def __init__(self, content: Optional[Content]): + self.content = content + + +class FakeProvider(Provider): + """Minimal provider that yields a predetermined sequence of content chunks.""" + + def __init__(self, chunks: Sequence[Optional[Content]]): + super().__init__(name="fake", model="fake-model") + self._chunks = chunks + + def list_models(self): + return [] + + def chat_perform(self, *, stream, turns, tools, data_model, kwargs): + if stream: + return iter([FakeChunk(c) for c in self._chunks]) + raise NotImplementedError + + async def chat_perform_async(self, *, stream, turns, tools, data_model, kwargs): + if stream: + + async def _gen(): + for c in self._chunks: + yield FakeChunk(c) + + return _gen() + raise NotImplementedError + + def stream_content(self, chunk) -> Optional[Content]: + return chunk.content + + def stream_merge_chunks(self, completion, chunk): + return completion or {} + + def stream_turn(self, completion, has_data_model): + return AssistantTurn( + contents=[ContentText.model_construct(text="response")], + tokens=None, + completion=None, + ) + + def value_turn(self, completion, has_data_model): + raise NotImplementedError + + def value_tokens(self, completion): + return None + + def value_cost(self, completion, tokens=None): + return None + + def token_count(self, *args, **kwargs): + return 0 + + async def token_count_async(self, *args, **kwargs): + return 0 + + def translate_model_params(self, *args, **kwargs): + return {} + + def supported_model_params(self): + return set() + + +def _make_chat(chunks: Sequence[Optional[Content]]) -> Chat: + provider = FakeProvider(chunks) + return Chat(provider=provider) + + +class TestStreamThinkingText: + """Tests for content='text' mode — tags should be yielded as string chunks.""" + + def test_thinking_then_text(self): + """Streaming thinking → text produces proper tag boundaries.""" + chunks = [ + ContentThinking(thinking="step 1 "), + ContentThinking(thinking="step 2"), + ContentText.model_construct(text="Hello world"), + ] + chat = _make_chat(chunks) + result = list(chat.stream("test")) + combined = "".join(result) + assert combined == "\nstep 1 step 2\n\n\nHello world" + + def test_thinking_only(self): + """If stream ends during thinking, close tag is still emitted.""" + chunks = [ + ContentThinking(thinking="reasoning here"), + ] + chat = _make_chat(chunks) + result = list(chat.stream("test")) + combined = "".join(result) + assert combined == "\nreasoning here\n\n\n" + + def test_text_only(self): + """No thinking chunks means no tags emitted.""" + chunks = [ + ContentText.model_construct(text="Just text"), + ] + chat = _make_chat(chunks) + result = list(chat.stream("test")) + combined = "".join(result) + assert combined == "Just text" + + def test_tag_chunks_are_separate(self): + """Opening and closing tags are yielded as separate chunks.""" + chunks = [ + ContentThinking(thinking="thought"), + ContentText.model_construct(text="answer"), + ] + chat = _make_chat(chunks) + result = list(chat.stream("test")) + assert result[0] == "\n" + assert result[1] == "thought" + assert result[2] == "\n\n\n" + assert result[3] == "answer" + + +class TestStreamThinkingAll: + """Tests for content='all' mode — ContentThinking objects yielded, no tag strings.""" + + def test_thinking_then_text(self): + """content='all' yields ContentThinking objects, not tag strings.""" + chunks = [ + ContentThinking(thinking="step 1 "), + ContentThinking(thinking="step 2"), + ContentText.model_construct(text="Hello"), + ] + chat = _make_chat(chunks) + result = list(chat.stream("test", content="all")) + + thinking_chunks = [x for x in result if isinstance(x, ContentThinking)] + text_chunks = [x for x in result if isinstance(x, str)] + + assert len(thinking_chunks) == 2 + assert thinking_chunks[0].thinking == "step 1 " + assert thinking_chunks[1].thinking == "step 2" + assert text_chunks == ["Hello"] + + def test_no_tag_strings_yielded(self): + """content='all' mode should NOT yield tag boundary strings.""" + chunks = [ + ContentThinking(thinking="thought"), + ContentText.model_construct(text="answer"), + ] + chat = _make_chat(chunks) + result = list(chat.stream("test", content="all")) + + str_chunks = [x for x in result if isinstance(x, str)] + for s in str_chunks: + assert "" not in s + assert "" not in s + + +@pytest.mark.asyncio +class TestStreamThinkingAsync: + """Tests for async streaming with thinking boundaries.""" + + async def test_thinking_then_text_async(self): + """Async streaming thinking → text produces proper tag boundaries.""" + chunks = [ + ContentThinking(thinking="async thought "), + ContentThinking(thinking="more"), + ContentText.model_construct(text="response"), + ] + chat = _make_chat(chunks) + result = [chunk async for chunk in await chat.stream_async("test")] + combined = "".join(result) + assert combined == "\nasync thought more\n\n\nresponse" + + async def test_thinking_only_async(self): + """Async: close tag emitted even if stream ends during thinking.""" + chunks = [ + ContentThinking(thinking="reasoning"), + ] + chat = _make_chat(chunks) + result = [chunk async for chunk in await chat.stream_async("test")] + combined = "".join(result) + assert combined == "\nreasoning\n\n\n" + + async def test_content_all_async(self): + """Async content='all' yields ContentThinking objects, no tag strings.""" + chunks = [ + ContentThinking(thinking="thought"), + ContentText.model_construct(text="answer"), + ] + chat = _make_chat(chunks) + result = [chunk async for chunk in await chat.stream_async("test", content="all")] + + thinking_chunks = [x for x in result if isinstance(x, ContentThinking)] + str_chunks = [x for x in result if isinstance(x, str)] + + assert len(thinking_chunks) == 1 + assert thinking_chunks[0].thinking == "thought" + assert str_chunks == ["answer"] + for s in str_chunks: + assert "" not in s From 13b5b5fc24b4b08685e981007e26cb5dcaba39c1 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 6 May 2026 17:20:35 -0500 Subject: [PATCH 2/4] docs: add changelog entry for streaming thinking tag boundaries --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d0bb9de..0d85ffed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Improvements +* `.stream()` and `.stream_async()` now emit `` / `` tag boundaries around thinking content. With `content="text"`, concatenating all chunks produces well-formed output with thinking delimited by a single tag pair. With `content="all"`, behavior is unchanged — typed `ContentThinking` objects are yielded without tag strings. (#294) * Updated default models across all providers to current generation: (#292) * Anthropic: `claude-sonnet-4-6` * Bedrock: `us.anthropic.claude-sonnet-4-6` From 73e387cb0ab25f8cd3e7ed063794859bf7ddbc08 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 6 May 2026 17:57:27 -0500 Subject: [PATCH 3/4] fix: ContentThinking.__str__() only wraps in tags when complete Streaming chunks are fragments, not complete thoughts. Adding a _complete PrivateAttr (default True) lets __str__() skip tag wrapping for chunks emitted during streaming, preventing repeated ... around each fragment in content="all" mode. Providers now use ContentThinking._as_chunk() for streaming fragments. --- chatlas/_content.py | 20 +++++++++++++++++-- chatlas/_provider_anthropic.py | 2 +- chatlas/_provider_google.py | 2 +- chatlas/_provider_openai.py | 2 +- tests/test_stream_thinking.py | 35 +++++++++++++++++++++++----------- 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/chatlas/_content.py b/chatlas/_content.py index 4fff0af5..1c88acd6 100644 --- a/chatlas/_content.py +++ b/chatlas/_content.py @@ -6,7 +6,14 @@ from typing import TYPE_CHECKING, Any, Literal, Optional, Union, cast import orjson -from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + PrivateAttr, + field_serializer, + field_validator, +) from ._typing_extensions import TypedDict @@ -624,11 +631,20 @@ class ContentThinking(Content): thinking: str extra: Optional[dict[str, Any]] = None + _complete: bool = PrivateAttr(default=True) content_type: ContentTypeEnum = "thinking" + @classmethod + def _as_chunk(cls, thinking: str) -> "ContentThinking": + obj = cls.model_construct(thinking=thinking, content_type="thinking") + obj._complete = False + return obj + def __str__(self): - return f"\n{self.thinking}\n\n" + if self._complete: + return f"\n{self.thinking}\n\n" + return self.thinking def _repr_html_(self): return str(self.tagify()) diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index cf8c07d1..f7db8e63 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -468,7 +468,7 @@ def stream_content(self, chunk) -> Optional[Content]: if chunk.delta.type == "text_delta": return ContentText.model_construct(text=chunk.delta.text) if chunk.delta.type == "thinking_delta": - return ContentThinking(thinking=chunk.delta.thinking) + return ContentThinking._as_chunk(chunk.delta.thinking) return None def stream_merge_chunks(self, completion, chunk): diff --git a/chatlas/_provider_google.py b/chatlas/_provider_google.py index f512071e..f74aa64f 100644 --- a/chatlas/_provider_google.py +++ b/chatlas/_provider_google.py @@ -377,7 +377,7 @@ def stream_content(self, chunk) -> Optional[Content]: if text is None: return None if getattr(part, "thought", None): - return ContentThinking(thinking=text) + return ContentThinking._as_chunk(text) return ContentText.model_construct(text=text) def stream_merge_chunks(self, completion, chunk): diff --git a/chatlas/_provider_openai.py b/chatlas/_provider_openai.py index 15adc33a..0ff81b4b 100644 --- a/chatlas/_provider_openai.py +++ b/chatlas/_provider_openai.py @@ -298,7 +298,7 @@ def stream_content(self, chunk) -> Optional[Content]: return ContentText.model_construct(text=chunk.delta) if chunk.type == "response.reasoning_summary_text.delta": # https://platform.openai.com/docs/api-reference/responses-streaming/response/reasoning_summary_text/delta - return ContentThinking(thinking=chunk.delta) + return ContentThinking._as_chunk(chunk.delta) if chunk.type == "response.reasoning_summary_text.done": # The thinking→text transition in _submit_turns already emits # "\n\n\n" which provides the visual separator. diff --git a/tests/test_stream_thinking.py b/tests/test_stream_thinking.py index 21156e9d..85b1ae86 100644 --- a/tests/test_stream_thinking.py +++ b/tests/test_stream_thinking.py @@ -88,8 +88,8 @@ class TestStreamThinkingText: def test_thinking_then_text(self): """Streaming thinking → text produces proper tag boundaries.""" chunks = [ - ContentThinking(thinking="step 1 "), - ContentThinking(thinking="step 2"), + ContentThinking._as_chunk("step 1 "), + ContentThinking._as_chunk("step 2"), ContentText.model_construct(text="Hello world"), ] chat = _make_chat(chunks) @@ -100,7 +100,7 @@ def test_thinking_then_text(self): def test_thinking_only(self): """If stream ends during thinking, close tag is still emitted.""" chunks = [ - ContentThinking(thinking="reasoning here"), + ContentThinking._as_chunk("reasoning here"), ] chat = _make_chat(chunks) result = list(chat.stream("test")) @@ -120,7 +120,7 @@ def test_text_only(self): def test_tag_chunks_are_separate(self): """Opening and closing tags are yielded as separate chunks.""" chunks = [ - ContentThinking(thinking="thought"), + ContentThinking._as_chunk("thought"), ContentText.model_construct(text="answer"), ] chat = _make_chat(chunks) @@ -137,8 +137,8 @@ class TestStreamThinkingAll: def test_thinking_then_text(self): """content='all' yields ContentThinking objects, not tag strings.""" chunks = [ - ContentThinking(thinking="step 1 "), - ContentThinking(thinking="step 2"), + ContentThinking._as_chunk("step 1 "), + ContentThinking._as_chunk("step 2"), ContentText.model_construct(text="Hello"), ] chat = _make_chat(chunks) @@ -155,7 +155,7 @@ def test_thinking_then_text(self): def test_no_tag_strings_yielded(self): """content='all' mode should NOT yield tag boundary strings.""" chunks = [ - ContentThinking(thinking="thought"), + ContentThinking._as_chunk("thought"), ContentText.model_construct(text="answer"), ] chat = _make_chat(chunks) @@ -166,6 +166,19 @@ def test_no_tag_strings_yielded(self): assert "" not in s assert "" not in s + def test_str_on_chunk_has_no_tags(self): + """Calling str() on yielded ContentThinking chunks should not wrap in tags.""" + chunks = [ + ContentThinking._as_chunk("thought"), + ContentText.model_construct(text="answer"), + ] + chat = _make_chat(chunks) + result = list(chat.stream("test", content="all")) + + thinking_chunks = [x for x in result if isinstance(x, ContentThinking)] + assert len(thinking_chunks) == 1 + assert str(thinking_chunks[0]) == "thought" + @pytest.mark.asyncio class TestStreamThinkingAsync: @@ -174,8 +187,8 @@ class TestStreamThinkingAsync: async def test_thinking_then_text_async(self): """Async streaming thinking → text produces proper tag boundaries.""" chunks = [ - ContentThinking(thinking="async thought "), - ContentThinking(thinking="more"), + ContentThinking._as_chunk("async thought "), + ContentThinking._as_chunk("more"), ContentText.model_construct(text="response"), ] chat = _make_chat(chunks) @@ -186,7 +199,7 @@ async def test_thinking_then_text_async(self): async def test_thinking_only_async(self): """Async: close tag emitted even if stream ends during thinking.""" chunks = [ - ContentThinking(thinking="reasoning"), + ContentThinking._as_chunk("reasoning"), ] chat = _make_chat(chunks) result = [chunk async for chunk in await chat.stream_async("test")] @@ -196,7 +209,7 @@ async def test_thinking_only_async(self): async def test_content_all_async(self): """Async content='all' yields ContentThinking objects, no tag strings.""" chunks = [ - ContentThinking(thinking="thought"), + ContentThinking._as_chunk("thought"), ContentText.model_construct(text="answer"), ] chat = _make_chat(chunks) From a1c68cc9cdca046edb8b01393cdb2fb7d65db69d Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 6 May 2026 18:59:54 -0500 Subject: [PATCH 4/4] fix: yield thinking tag boundaries in content='all' mode Previously `\n` and `\n\n\n` boundary strings were only yielded to consumers when `content="text"`. Remove that guard so they are emitted in all modes, including `content="all"`. This is required by shinychat PR posit-dev/shinychat#210, which removes server-side thinking detection and relies on the client tag parser seeing `` markers in the stream. Without this fix, tool-use apps (which require `content="all"`) with thinking-capable models render thinking content as regular assistant text. Both the sync and async `_submit_turns` paths are updated. Tests in `TestStreamThinkingAll` and the async `test_content_all_async` are updated to assert that tag boundary strings ARE present in the output. --- chatlas/_chat.py | 18 +++++--------- tests/test_stream_thinking.py | 44 ++++++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/chatlas/_chat.py b/chatlas/_chat.py index 16c196c2..6b1a2705 100644 --- a/chatlas/_chat.py +++ b/chatlas/_chat.py @@ -2681,13 +2681,11 @@ def emit(text: str | Content): is_thinking = isinstance(content, ContentThinking) if is_thinking and not inside_thinking: emit("\n") - if content_mode == "text": - yield "\n" + yield "\n" inside_thinking = True elif not is_thinking and inside_thinking: emit("\n\n\n") - if content_mode == "text": - yield "\n\n\n" + yield "\n\n\n" inside_thinking = False emit(text) @@ -2699,8 +2697,7 @@ def emit(text: str | Content): if inside_thinking: emit("\n\n\n") - if content_mode == "text": - yield "\n\n\n" + yield "\n\n\n" turn = self.provider.stream_turn( result, @@ -2804,13 +2801,11 @@ def emit(text: str | Content): is_thinking = isinstance(content, ContentThinking) if is_thinking and not inside_thinking: emit("\n") - if content_mode == "text": - yield "\n" + yield "\n" inside_thinking = True elif not is_thinking and inside_thinking: emit("\n\n\n") - if content_mode == "text": - yield "\n\n\n" + yield "\n\n\n" inside_thinking = False emit(text) @@ -2822,8 +2817,7 @@ def emit(text: str | Content): if inside_thinking: emit("\n\n\n") - if content_mode == "text": - yield "\n\n\n" + yield "\n\n\n" turn = self.provider.stream_turn( result, diff --git a/tests/test_stream_thinking.py b/tests/test_stream_thinking.py index 85b1ae86..a5ad76d0 100644 --- a/tests/test_stream_thinking.py +++ b/tests/test_stream_thinking.py @@ -132,10 +132,10 @@ def test_tag_chunks_are_separate(self): class TestStreamThinkingAll: - """Tests for content='all' mode — ContentThinking objects yielded, no tag strings.""" + """Tests for content='all' mode — ContentThinking objects AND tag boundary strings yielded.""" def test_thinking_then_text(self): - """content='all' yields ContentThinking objects, not tag strings.""" + """content='all' yields tag boundaries AND ContentThinking objects.""" chunks = [ ContentThinking._as_chunk("step 1 "), ContentThinking._as_chunk("step 2"), @@ -145,15 +145,17 @@ def test_thinking_then_text(self): result = list(chat.stream("test", content="all")) thinking_chunks = [x for x in result if isinstance(x, ContentThinking)] - text_chunks = [x for x in result if isinstance(x, str)] + str_chunks = [x for x in result if isinstance(x, str)] assert len(thinking_chunks) == 2 assert thinking_chunks[0].thinking == "step 1 " assert thinking_chunks[1].thinking == "step 2" - assert text_chunks == ["Hello"] + assert "\n" in str_chunks + assert "\n\n\n" in str_chunks + assert "Hello" in str_chunks - def test_no_tag_strings_yielded(self): - """content='all' mode should NOT yield tag boundary strings.""" + def test_tag_boundaries_yielded(self): + """content='all' mode SHOULD yield tag boundary strings.""" chunks = [ ContentThinking._as_chunk("thought"), ContentText.model_construct(text="answer"), @@ -162,9 +164,23 @@ def test_no_tag_strings_yielded(self): result = list(chat.stream("test", content="all")) str_chunks = [x for x in result if isinstance(x, str)] - for s in str_chunks: - assert "" not in s - assert "" not in s + assert "\n" in str_chunks + assert "\n\n\n" in str_chunks + + def test_order_of_chunks(self): + """content='all' mode: open tag, ContentThinking objects, close tag, then text.""" + chunks = [ + ContentThinking._as_chunk("thought"), + ContentText.model_construct(text="answer"), + ] + chat = _make_chat(chunks) + result = list(chat.stream("test", content="all")) + + assert result[0] == "\n" + assert isinstance(result[1], ContentThinking) + assert result[1].thinking == "thought" + assert result[2] == "\n\n\n" + assert result[3] == "answer" def test_str_on_chunk_has_no_tags(self): """Calling str() on yielded ContentThinking chunks should not wrap in tags.""" @@ -207,7 +223,7 @@ async def test_thinking_only_async(self): assert combined == "\nreasoning\n\n\n" async def test_content_all_async(self): - """Async content='all' yields ContentThinking objects, no tag strings.""" + """Async content='all' yields tag boundaries AND ContentThinking objects.""" chunks = [ ContentThinking._as_chunk("thought"), ContentText.model_construct(text="answer"), @@ -220,6 +236,8 @@ async def test_content_all_async(self): assert len(thinking_chunks) == 1 assert thinking_chunks[0].thinking == "thought" - assert str_chunks == ["answer"] - for s in str_chunks: - assert "" not in s + assert "\n" in str_chunks + assert "\n\n\n" in str_chunks + assert "answer" in str_chunks + assert result[0] == "\n" + assert result[2] == "\n\n\n"