diff --git a/src/tau_ai/openai_codex.py b/src/tau_ai/openai_codex.py index aa4bf042b..6a678c7a8 100644 --- a/src/tau_ai/openai_codex.py +++ b/src/tau_ai/openai_codex.py @@ -543,6 +543,12 @@ async def _codex_provider_events( thinking_parts.append(delta) yield ProviderThinkingDeltaEvent(delta=delta) + elif event_type == "response.reasoning_summary_part.done": + if thinking_parts: + separator = "\n\n" + thinking_parts.append(separator) + yield ProviderThinkingDeltaEvent(delta=separator) + elif event_type in { "response.output_item.done", "response.output_item.completed", diff --git a/src/tau_ai/openai_compatible.py b/src/tau_ai/openai_compatible.py index dd1e5d0e8..bad24df4e 100644 --- a/src/tau_ai/openai_compatible.py +++ b/src/tau_ai/openai_compatible.py @@ -482,6 +482,12 @@ def feed(self, event: str) -> tuple[list[ProviderEvent], bool]: self._thinking_parts.append(delta) return [ProviderThinkingDeltaEvent(delta=delta)], False + elif chunk_type == "response.reasoning_summary_part.done": + if self._thinking_parts: + separator = "\n\n" + self._thinking_parts.append(separator) + return [ProviderThinkingDeltaEvent(delta=separator)], False + elif chunk_type == "response.output_item.added": item = chunk.get("item") _register_reasoning_item(self._reasoning_items, item) diff --git a/tests/test_tau_ai.py b/tests/test_tau_ai.py index 78f314431..3a9025844 100644 --- a/tests/test_tau_ai.py +++ b/tests/test_tau_ai.py @@ -1371,6 +1371,59 @@ def handler(_request: httpx.Request) -> httpx.Response: assert events[-1].message.text == "Done" +@pytest.mark.anyio +async def test_openai_codex_provider_preserves_reasoning_summary_part_boundaries() -> None: + async def credentials() -> OpenAICodexCredentials: + return OpenAICodexCredentials(access_token="access-token", account_id="account-1") + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + text=( + 'data: {"type":"response.reasoning_summary_text.delta",' + '"delta":"**First step**"}\n\n' + 'data: {"type":"response.reasoning_summary_part.done"}\n\n' + 'data: {"type":"response.reasoning_summary_text.delta",' + '"delta":"**Second step**"}\n\n' + 'data: {"type":"response.reasoning_summary_part.done"}\n\n' + 'data: {"type":"response.output_text.delta","delta":"Done"}\n\n' + 'data: {"type":"response.completed","response":{"status":"completed"}}\n\n' + ), + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = OpenAICodexProvider( + OpenAICodexConfig( + credential_resolver=credentials, + base_url="https://chatgpt.test/backend-api", + ), + client=client, + ) + + events = await _collect( + provider.stream_response( + model="gpt-5.5", + system="You are Tau.", + messages=[UserMessage(content="Say done")], + tools=[], + ) + ) + + thinking_events = [event for event in events if isinstance(event, ThinkingDeltaEvent)] + assert [event.delta for event in thinking_events] == [ + "**First step**", + "\n\n", + "**Second step**", + "\n\n", + ] + end = events[-1] + assert isinstance(end, AssistantDoneEvent) + thinking = end.message.content[0] + assert isinstance(thinking, ThinkingContent) + assert thinking.thinking == "**First step**\n\n**Second step**\n\n" + + @pytest.mark.anyio async def test_openai_codex_provider_streams_tool_calls() -> None: async def credentials() -> OpenAICodexCredentials: @@ -2053,6 +2106,57 @@ def handler(_request: httpx.Request) -> httpx.Response: assert thinking.delta == "Considering" +@pytest.mark.anyio +async def test_responses_api_preserves_reasoning_summary_part_boundaries() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + text=( + 'data: {"type":"response.reasoning_summary_text.delta",' + '"delta":"**First step**"}\n\n' + 'data: {"type":"response.reasoning_summary_part.done"}\n\n' + 'data: {"type":"response.reasoning_summary_text.delta",' + '"delta":"**Second step**"}\n\n' + 'data: {"type":"response.reasoning_summary_part.done"}\n\n' + 'data: {"type":"response.output_text.delta","delta":"Answer"}\n\n' + 'data: {"type":"response.completed","response":{"status":"completed"}}\n\n' + ), + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = OpenAICompatibleProvider( + OpenAICompatibleConfig( + api_key="test-key", + base_url="https://example.test/v1", + reasoning_effort="high", + ), + client=client, + ) + + events = await _collect( + provider.stream_response( + model="gpt-5.5", + system="You are Tau.", + messages=[UserMessage(content="think")], + tools=[], + ) + ) + + thinking_events = [event for event in events if isinstance(event, ThinkingDeltaEvent)] + assert [event.delta for event in thinking_events] == [ + "**First step**", + "\n\n", + "**Second step**", + "\n\n", + ] + end = events[-1] + assert isinstance(end, AssistantDoneEvent) + thinking = end.message.content[0] + assert isinstance(thinking, ThinkingContent) + assert thinking.thinking == "**First step**\n\n**Second step**\n\n" + + @pytest.mark.anyio async def test_responses_api_omits_reasoning_when_effort_is_none() -> None: requests: list[httpx.Request] = []