diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0d85ffed..cc32d81d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,7 +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)
+* `.stream()` and `.stream_async()` now emit `` / `` tag boundaries around thinking content in all stream modes. With `content="text"`, concatenating all chunks produces well-formed output with thinking delimited by a single tag pair. With `content="all"`, tag boundary strings are yielded alongside typed `ContentThinking` objects, so downstream consumers can detect thinking boundaries without type inspection. (#294, #297)
* Updated default models across all providers to current generation: (#292)
* Anthropic: `claude-sonnet-4-6`
* Bedrock: `us.anthropic.claude-sonnet-4-6`
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"