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"