diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index b35a4fe23d9..20ef337ef39 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -616,7 +616,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Declaration-only mixed batch | Known local siblings are never converted into Host pauses. With an authoritative session they remain deferred until every Host pause occurrence is answered, survive session serialization with a process-portable logical-run budget (including a reclassified approval continuation), reclassify against the current tool policy, execute exactly once using the configured concurrent or sequential invocation mode, replay results in original model order, and remain inert on replay; without one they execute on the same turn under the same configured invocation mode. Streaming updates and finalization preserve one Host pause and one result per local sibling. | `test_mixed_declaration_only_batch_defers_siblings_until_all_host_results`, `test_mixed_declaration_only_deferred_call_keeps_ordinary_middleware_semantics`, `test_mixed_declaration_only_deferred_call_reclassifies_changed_approval_policy`, `test_mixed_declaration_only_budget_survives_reclassification_to_approval`, `test_mixed_declaration_only_deferred_call_does_not_execute_removed_tool`, `test_mixed_declaration_only_budget_is_process_portable_without_approval_middleware`, `test_sessionless_mixed_declaration_only_batch_executes_siblings_sequentially`, `test_sessionless_mixed_declaration_only_batch_executes_siblings_concurrently`, `test_streaming_sessionless_mixed_declaration_only_batch_returns_pause_and_result` | | Function invocation disabled | The client bypasses the invocation loop without losing invocation kwargs. | `test_function_invocation_config_enabled_false`, `test_function_invocation_config_enabled_false_preserves_invocation_kwargs`, `test_streaming_function_invocation_config_enabled_false` | | Runtime tool changes | Added tools become available on the next iteration and retain approval behavior. | `test_add_tools_available_next_iteration`, `test_add_tools_with_approval_required_tool` | -| Anthropic pause-turn continuation | `pause_turn` replays the returned assistant content with the same tools until Anthropic completes the server-side turn in streaming and non-streaming modes when every native content block can be reconstructed faithfully. Intermediate messages and usage remain caller-visible and retain provider message boundaries. Unsupported parsed content, native blocks dropped during parsing (including redacted reasoning), streamed MCP calls whose argument deltas are not retained, and bounded-continuation exhaustion keep `finish_reason="pause_turn"` instead of sending a lossy transcript or reporting normal completion. | `packages/anthropic/tests/test_anthropic_client.py::test_agent_continues_anthropic_pause_turn`, `test_streaming_hosted_tool_pause_turn_continues`, `test_streaming_pause_turn_preserves_message_boundaries_for_structured_output`, `test_non_streaming_code_execution_pause_turn_is_not_auto_continued`, `test_streaming_code_execution_pause_turn_is_not_auto_continued`, `test_streaming_mcp_pause_turn_is_not_auto_continued`, `test_non_streaming_redacted_thinking_pause_turn_is_not_auto_continued`, `test_streaming_redacted_thinking_pause_turn_is_not_auto_continued`, `test_anthropic_pause_turn_limit_preserves_finish_reason` | +| Anthropic pause-turn continuation | `pause_turn` replays the returned assistant content with the same tools until Anthropic completes the server-side turn in streaming and non-streaming modes when every native content block can be reconstructed faithfully. Intermediate messages and usage remain caller-visible and retain provider message boundaries. Unsupported parsed content, native blocks dropped during parsing, streamed MCP calls whose argument deltas are not retained, and bounded-continuation exhaustion keep `finish_reason="pause_turn"` instead of sending a lossy transcript or reporting normal completion. | `packages/anthropic/tests/test_anthropic_client.py::test_agent_continues_anthropic_pause_turn`, `test_streaming_hosted_tool_pause_turn_continues`, `test_streaming_pause_turn_preserves_message_boundaries_for_structured_output`, `test_non_streaming_code_execution_pause_turn_is_not_auto_continued`, `test_streaming_code_execution_pause_turn_is_not_auto_continued`, `test_streaming_mcp_pause_turn_is_not_auto_continued`, `test_non_streaming_redacted_thinking_pause_turn_continues`, `test_streaming_redacted_thinking_pause_turn_continues`, `test_anthropic_pause_turn_limit_preserves_finish_reason` | | Direct delegated session propagation | A custom loop must explicitly identify parent provider-owned state before sharing a non-empty session; missing ownership fails before the child runs, while declared parent keys remain isolated and application state still propagates. | `packages/core/tests/core/test_agents.py::test_chat_agent_as_tool_direct_propagation_requires_parent_provider_ownership`, `test_chat_agent_as_tool_direct_propagation_uses_explicit_parent_provider_ownership` | | In-run compaction summaries | Summaries inserted for tool groups excluded by in-run compaction are returned in the final non-streaming response transcript before the group they replace, so history loaded with `skip_excluded` keeps the summarized content; summaries of caller-owned input messages are not added. | `packages/core/tests/core/test_clients.py::test_function_loop_returns_compaction_summaries_in_final_response`, `test_function_loop_returns_compaction_summaries_when_iteration_budget_exhausted`, `test_function_loop_reconciles_nested_compaction_summaries`, `test_function_loop_returns_compacted_transcript_on_early_terminal_exit`, `packages/core/tests/core/test_agents.py::test_agent_run_returns_and_persists_compaction_summaries` | | Per-service-call provider context | When framework-managed history is loaded around each model call, tools and instructions contributed by `HistoryProvider.before_run` remain available to the model and function loop without duplication in streaming or non-streaming execution. | `packages/core/tests/core/test_agents.py::test_vector_history_search_tool_is_available_with_per_service_call_persistence` | diff --git a/docs/specs/005-python-content-identity-and-protocol-metadata.md b/docs/specs/005-python-content-identity-and-protocol-metadata.md index 040c7528bb5..e4b41bd95d7 100644 --- a/docs/specs/005-python-content-identity-and-protocol-metadata.md +++ b/docs/specs/005-python-content-identity-and-protocol-metadata.md @@ -179,6 +179,7 @@ Example: | OpenAI | OAuth consent request server | `server_label` | `openai["server_label"]` | Read indefinitely; dual-write through the writer package's next major version. | | Foundry | reasoning content | `__foundry_reasoning_replay_item__` | `foundry["reasoning_item"]` | Read indefinitely; dual-write through the Foundry package's next major version. | | Bedrock | redacted reasoning marker | `bedrock.redacted_reasoning` | `bedrock["redacted_reasoning"]` | Read indefinitely; dual-write through the Bedrock package's next major version. | +| Anthropic | native reasoning block type | N/A | `anthropic["reasoning_block"]` | New metadata; values are the Anthropic block types `thinking` and `redacted_thinking`, and a redacted payload uses `Content.protected_data`. | | Mistral | response service tier | `service_tier` | `mistral["service_tier"]` | Read indefinitely; dual-write through the Mistral package's next major version. | | A2A | response/update/message metadata | `a2a_metadata` | `a2a` | Read indefinitely with a warning for legacy-only input; dual-write through the A2A package's next major version. | | all | data/URI/hosted-file display name | `filename` | `Content.name` | Read indefinitely where accepted; dual-write where older adapters require it. | @@ -242,7 +243,7 @@ merge_additional_properties(existing, incoming, *, incoming_wins=True) | AG-UI stream merge modes, hosted/local ownership, and approval replay | `python/packages/ag-ui/tests/ag_ui/test_event_converters.py`, `test_run.py`, `test_workflow_run.py`, and `test_endpoint.py` | | A2A stream merge modes and namespaced metadata round trip | `python/packages/a2a/tests/test_a2a_agent.py` | | Hosting A2A message metadata conversion | `python/packages/hosting-a2a/tests/hosting_a2a/test_conversion.py` | -| Anthropic streamed merge modes and provider-hosted tool routing | `python/packages/anthropic/tests/test_anthropic_client.py` | +| Anthropic streamed merge modes, provider-hosted tool routing, and redacted-thinking replay metadata | `python/packages/anthropic/tests/test_anthropic_client.py` | | Bedrock streamed merge modes and redacted-reasoning replay metadata | `python/packages/bedrock/tests/test_bedrock_client.py` | | Gemini streamed merge modes and persisted provider-hosted tool replay | `python/packages/gemini/tests/test_gemini_client.py` | | Mistral streamed merge modes and response metadata | `python/packages/mistral/tests/mistral/test_mistral_chat_client.py` | diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 1a01392ada4..a27fb504eab 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -9,6 +9,7 @@ from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field from typing import Any, ClassVar, Final, Generic, Literal, TypeAlias, TypedDict, cast +from uuid import uuid4 from agent_framework import ( Annotation, @@ -106,6 +107,12 @@ "web_search_tool_result", }) _MAX_PAUSE_TURN_CONTINUATIONS: Final[int] = 10 +# Marks text_reasoning content parsed from a native Anthropic reasoning block; the value is the +# Anthropic block type. Distinguishes native reasoning items from provider reasoning summaries +# that carry a Content.id without a protected payload. +_REASONING_BLOCK_KEY: Final[str] = "reasoning_block" +_REASONING_BLOCK_THINKING: Final[str] = "thinking" +_REASONING_BLOCK_REDACTED_THINKING: Final[str] = "redacted_thinking" _PAUSE_TURN_REPLAYABLE_CONTENT_TYPES: Final[frozenset[str]] = frozenset({ "text", "text_reasoning", @@ -835,6 +842,8 @@ def _append_pause_turn_messages_for_anthropic( def _is_pause_turn_content_replayable(self, content: Content) -> bool: """Return whether the Anthropic serializer preserves this paused content item.""" + if content.type == "text_reasoning" and self._is_redacted_thinking_content(content): + return bool(content.protected_data) if content.type in _PAUSE_TURN_REPLAYABLE_CONTENT_TYPES: return True if content.type == "function_call": @@ -1130,6 +1139,36 @@ def _message_has_tool_use(self, message: dict[str, Any]) -> bool: return False + @staticmethod + def _generate_reasoning_id() -> str: + """Return a fresh identity for one reasoning item. + + Anthropic reasoning blocks have no provider identity of their own, and response + aggregation folds adjacent reasoning chunks without ids into one item. Giving each + parsed reasoning block its own id keeps consecutive ``thinking`` and + ``redacted_thinking`` blocks distinct when a streamed response is finalized. + """ + return f"reasoning-{uuid4().hex}" + + @staticmethod + def _native_reasoning_block_type(content: Content) -> Any: + """Return the Anthropic native block type recorded on a reasoning item, if any.""" + return get_namespaced_value( + content.additional_properties, + _ANTHROPIC_ADDITIONAL_PROPS_NAMESPACE, + _REASONING_BLOCK_KEY, + ) + + @classmethod + def _is_redacted_thinking_content(cls, content: Content) -> bool: + """Return whether this reasoning item carries an Anthropic redacted-thinking payload.""" + return cls._native_reasoning_block_type(content) == _REASONING_BLOCK_REDACTED_THINKING + + @classmethod + def _is_thinking_content(cls, content: Content) -> bool: + """Return whether this reasoning item was parsed from an Anthropic ``thinking`` block.""" + return cls._native_reasoning_block_type(content) == _REASONING_BLOCK_THINKING + @staticmethod def _server_tool_block(content: Content) -> Mapping[str, Any] | None: """Return validated persisted Anthropic server-tool metadata.""" @@ -1363,6 +1402,11 @@ def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: mcp_result["is_error"] = is_error a_content.append(mcp_result) case "text_reasoning": + if self._is_redacted_thinking_content(content): + if not content.protected_data: + raise ContentError("Anthropic redacted thinking content is missing its opaque payload.") + a_content.append({"type": "redacted_thinking", "data": content.protected_data}) + continue if content.text is None: if ( content.protected_data @@ -1372,7 +1416,7 @@ def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: ): a_content[-1]["signature"] = content.protected_data continue - if content.id and not content.protected_data: + if content.id and not content.protected_data and not self._is_thinking_content(content): a_content.append({"type": "text", "text": content.text}) continue thinking_block: dict[str, Any] = {"type": "thinking", "thinking": content.text} @@ -2081,11 +2125,37 @@ def _parse_contents_from_anthropic( raw_representation=content_block, ) ) + case "redacted_thinking": + # Redacted reasoning is opaque provider state. Anthropic requires it to be replayed + # unchanged while tool use continues, so keep it as its own reasoning item. + contents.append( + Content.from_text_reasoning( + id=self._generate_reasoning_id(), + protected_data=content_block.data, + additional_properties={ + _ANTHROPIC_ADDITIONAL_PROPS_NAMESPACE: { + _REASONING_BLOCK_KEY: _REASONING_BLOCK_REDACTED_THINKING + } + }, + merge_mode="replace" if streaming else None, + raw_representation=content_block, + ) + ) case "thinking" | "thinking_delta": contents.append( Content.from_text_reasoning( + id=self._generate_reasoning_id() if content_block.type == "thinking" else None, text=content_block.thinking, protected_data=getattr(content_block, "signature", None), + additional_properties=( + { + _ANTHROPIC_ADDITIONAL_PROPS_NAMESPACE: { + _REASONING_BLOCK_KEY: _REASONING_BLOCK_THINKING + } + } + if content_block.type == "thinking" + else None + ), merge_mode="append" if streaming else None, raw_representation=content_block, ) diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index a4cbff8f5fd..15f9b264fee 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -48,10 +48,12 @@ BetaMessageDeltaUsage, BetaRawContentBlockDeltaEvent, BetaRawContentBlockStartEvent, + BetaRawMessageStreamEvent, BetaRedactedThinkingBlock, BetaServerToolUseBlock, BetaTextBlock, BetaTextDelta, + BetaThinkingBlock, BetaToolUseBlock, BetaUsage, BetaWebSearchResultBlock, @@ -673,6 +675,27 @@ def test_prepare_message_for_anthropic_text_reasoning_with_signature( assert result["content"][0]["signature"] == "sig_abc123" +def test_prepare_message_for_anthropic_unsigned_native_thinking_is_thinking( + mock_anthropic_client: MagicMock, +) -> None: + """An unsigned Anthropic thinking block still replays as thinking, not as text. + + Parsed thinking items carry a generated Content.id, which is also used for + provider reasoning summaries. The native block marker keeps the two apart. + """ + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="assistant", + contents=client._parse_contents_from_anthropic([ + BetaThinkingBlock(type="thinking", thinking="Visible summary.", signature="") + ]), + ) + + result = client._prepare_message_for_anthropic(message) + + assert result["content"] == [{"type": "thinking", "thinking": "Visible summary."}] + + def test_prepare_message_for_anthropic_provider_reasoning_without_signature_is_text( mock_anthropic_client: MagicMock, ) -> None: @@ -738,6 +761,171 @@ def test_prepare_message_for_anthropic_empty_signed_thinking_is_replayed( assert result["content"][1]["id"] == "toolu_test" +def test_prepare_message_for_anthropic_redacted_thinking_with_tool_use( + mock_anthropic_client: MagicMock, +) -> None: + """Redacted reasoning round-trips unchanged before a tool call. + + Anthropic requires the thinking blocks of the last assistant turn, including + ``redacted_thinking``, to be replayed unmodified while tool use continues. + """ + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="assistant", + contents=client._parse_contents_from_anthropic([ + BetaRedactedThinkingBlock(type="redacted_thinking", data="synthetic-opaque-data"), + BetaToolUseBlock(type="tool_use", id="toolu_test", name="lookup", input={}), + ]), + ) + + result = client._prepare_message_for_anthropic(message) + + assert result["content"] == [ + {"type": "redacted_thinking", "data": "synthetic-opaque-data"}, + {"type": "tool_use", "id": "toolu_test", "name": "lookup", "input": {}}, + ] + + +def test_parse_then_replay_anthropic_redacted_thinking_preserves_payload( + mock_anthropic_client: MagicMock, +) -> None: + """Persisted history replays redacted reasoning without its raw SDK representation.""" + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="assistant", + contents=client._parse_contents_from_anthropic([ + BetaRedactedThinkingBlock(type="redacted_thinking", data="synthetic-opaque-data"), + BetaToolUseBlock(type="tool_use", id="toolu_test", name="lookup", input={}), + ]), + ) + + restored = Message.from_dict(message.to_dict()) + prepared = client._prepare_message_for_anthropic(restored) + + assert prepared["content"] == [ + {"type": "redacted_thinking", "data": "synthetic-opaque-data"}, + {"type": "tool_use", "id": "toolu_test", "name": "lookup", "input": {}}, + ] + + +def test_prepare_message_for_anthropic_thinking_then_redacted_thinking_with_tool_use( + mock_anthropic_client: MagicMock, +) -> None: + """A normal thinking block and a redacted thinking block stay separate blocks.""" + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="assistant", + contents=client._parse_contents_from_anthropic([ + BetaThinkingBlock(type="thinking", thinking="Visible summary.", signature="synthetic-signature"), + BetaRedactedThinkingBlock(type="redacted_thinking", data="synthetic-opaque-data"), + BetaToolUseBlock(type="tool_use", id="toolu_test", name="lookup", input={}), + ]), + ) + + result = client._prepare_message_for_anthropic(message) + + assert [block["type"] for block in result["content"]] == ["thinking", "redacted_thinking", "tool_use"] + assert result["content"][0] == { + "type": "thinking", + "thinking": "Visible summary.", + "signature": "synthetic-signature", + } + assert result["content"][1] == {"type": "redacted_thinking", "data": "synthetic-opaque-data"} + + +def test_prepare_message_for_anthropic_redacted_thinking_without_payload_raises( + mock_anthropic_client: MagicMock, +) -> None: + """A marked redacted reasoning item without its opaque payload fails loudly instead of vanishing.""" + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="assistant", + contents=[ + Content.from_text_reasoning( + id="reasoning-test", + additional_properties={"anthropic": {"reasoning_block": "redacted_thinking"}}, + ), + Content.from_function_call(call_id="toolu_test", name="lookup", arguments={}), + ], + ) + + with pytest.raises(ContentError, match="redacted thinking"): + client._prepare_message_for_anthropic(message) + + +def test_streaming_replay_preserves_redacted_thinking_block( + mock_anthropic_client: MagicMock, +) -> None: + """Streamed redacted reasoning stays its own reasoning item and is replayed before tool_use. + + Offline synthetic SDK events — no network. Regression for microsoft/agent-framework#10104. + """ + client = create_test_anthropic_client(mock_anthropic_client) + + events: list[BetaRawMessageStreamEvent] = [ + BetaRawContentBlockStartEvent.model_validate({ + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": "Visible summary.", "signature": ""}, + }), + BetaRawContentBlockDeltaEvent.model_validate({ + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": "synthetic-signature"}, + }), + BetaRawContentBlockStartEvent.model_validate({ + "type": "content_block_start", + "index": 1, + "content_block": {"type": "redacted_thinking", "data": "synthetic-opaque-data"}, + }), + BetaRawContentBlockStartEvent.model_validate({ + "type": "content_block_start", + "index": 2, + "content_block": {"type": "tool_use", "id": "toolu_test", "name": "lookup", "input": {}}, + }), + ] + + updates = [client._process_stream_event(event) for event in events] + response = ChatResponse.from_updates([update for update in updates if update is not None]) + message = response.messages[0] + reasoning = [content for content in message.contents if content.type == "text_reasoning"] + assert len(reasoning) == 2 + + prepared = client._prepare_message_for_anthropic(message) + assert [block["type"] for block in prepared["content"]] == ["thinking", "redacted_thinking", "tool_use"] + assert prepared["content"][0]["signature"] == "synthetic-signature" + assert prepared["content"][1]["data"] == "synthetic-opaque-data" + + +def test_streaming_replay_preserves_only_redacted_thinking_block( + mock_anthropic_client: MagicMock, +) -> None: + """A streamed response containing only a redacted thinking block still replays it.""" + client = create_test_anthropic_client(mock_anthropic_client) + + events: list[BetaRawMessageStreamEvent] = [ + BetaRawContentBlockStartEvent.model_validate({ + "type": "content_block_start", + "index": 0, + "content_block": {"type": "redacted_thinking", "data": "synthetic-opaque-data"}, + }), + BetaRawContentBlockStartEvent.model_validate({ + "type": "content_block_start", + "index": 1, + "content_block": {"type": "tool_use", "id": "toolu_test", "name": "lookup", "input": {}}, + }), + ] + + updates = [client._process_stream_event(event) for event in events] + response = ChatResponse.from_updates([update for update in updates if update is not None]) + + prepared = client._prepare_message_for_anthropic(response.messages[0]) + assert prepared["content"] == [ + {"type": "redacted_thinking", "data": "synthetic-opaque-data"}, + {"type": "tool_use", "id": "toolu_test", "name": "lookup", "input": {}}, + ] + + def test_streaming_replay_preserves_empty_signed_thinking_block( mock_anthropic_client: MagicMock, ) -> None: @@ -2368,10 +2556,10 @@ def test_parse_contents_from_anthropic_text(mock_anthropic_client: MagicMock) -> assert result[0].text == "Hello!" -def test_parse_contents_records_dropped_redacted_thinking( +def test_parse_contents_preserves_redacted_thinking( mock_anthropic_client: MagicMock, ) -> None: - """Dropped native blocks remain visible to the pause-turn replay guard.""" + """Redacted reasoning is kept as an opaque reasoning item instead of being dropped.""" client = create_test_anthropic_client(mock_anthropic_client) request_state = _AnthropicRequestState() @@ -2380,8 +2568,12 @@ def test_parse_contents_records_dropped_redacted_thinking( request_state, ) - assert result == [] - assert request_state.dropped_content_types == {"redacted_thinking"} + assert len(result) == 1 + assert result[0].type == "text_reasoning" + assert result[0].text is None + assert result[0].protected_data == "opaque" + assert result[0].additional_properties["anthropic"]["reasoning_block"] == "redacted_thinking" + assert request_state.dropped_content_types == set() def test_parse_contents_from_anthropic_tool_use( @@ -3944,11 +4136,11 @@ async def events() -> Any: assert response.messages[0].contents[0].parse_arguments() == {} -async def test_non_streaming_redacted_thinking_pause_turn_is_not_auto_continued( +async def test_non_streaming_redacted_thinking_pause_turn_continues( mock_anthropic_client: MagicMock, ) -> None: - """Dropped redacted reasoning prevents an incomplete continuation transcript.""" - message = BetaMessage( + """Redacted reasoning is replayed faithfully, so a paused turn is continued.""" + paused = BetaMessage( id="msg_redacted_pause", content=[ BetaRedactedThinkingBlock(type="redacted_thinking", data="opaque"), @@ -3960,7 +4152,22 @@ async def test_non_streaming_redacted_thinking_pause_turn_is_not_auto_continued( type="message", usage=BetaUsage(input_tokens=1, output_tokens=1), ) - mock_anthropic_client.beta.messages.create.return_value = message + finished = BetaMessage( + id="msg_finished", + content=[BetaTextBlock(type="text", text="Finished.")], + model="claude-test", + role="assistant", + stop_reason="end_turn", + type="message", + usage=BetaUsage(input_tokens=2, output_tokens=1), + ) + requests: list[dict[str, Any]] = [] + + async def create(**kwargs: Any) -> BetaMessage: + requests.append(kwargs) + return [paused, finished][len(requests) - 1] + + mock_anthropic_client.beta.messages.create.side_effect = create client = create_test_anthropic_client(mock_anthropic_client) response = await client._inner_get_response( # type: ignore[attr-defined] @@ -3968,17 +4175,21 @@ async def test_non_streaming_redacted_thinking_pause_turn_is_not_auto_continued( options={"max_tokens": 64}, ) - assert mock_anthropic_client.beta.messages.create.call_count == 1 - assert response.finish_reason == "pause_turn" - assert response.text == "Still working." + assert len(requests) == 2 + assert requests[1]["messages"][1]["content"] == [ + {"type": "redacted_thinking", "data": "opaque"}, + {"type": "text", "text": "Still working."}, + ] + assert response.finish_reason == "stop" + assert [message.text for message in response.messages] == ["Still working.", "Finished."] -async def test_streaming_redacted_thinking_pause_turn_is_not_auto_continued( +async def test_streaming_redacted_thinking_pause_turn_continues( mock_anthropic_client: MagicMock, ) -> None: - """Streaming also retains pause_turn when redacted reasoning was dropped.""" + """Streaming keeps redacted reasoning replayable, so a paused turn is continued.""" - async def events() -> Any: + async def paused_events() -> Any: yield _message_start_event("msg_redacted_pause") redacted_start = MagicMock() redacted_start.type = "content_block_start" @@ -3997,7 +4208,27 @@ async def events() -> Any: yield _message_delta_event("pause_turn") yield _message_stop_event() - mock_anthropic_client.beta.messages.create.return_value = events() + async def final_events() -> Any: + yield _message_start_event("msg_finished") + text_start = MagicMock() + text_start.type = "content_block_start" + text_start.index = 0 + text_start.content_block.type = "text" + text_start.content_block.text = "Finished." + text_start.content_block.citations = None + yield text_start + yield _content_block_stop_event(index=0) + yield _message_delta_event("end_turn") + yield _message_stop_event() + + requests: list[dict[str, Any]] = [] + streams = [paused_events(), final_events()] + + async def create(**kwargs: Any) -> Any: + requests.append(kwargs) + return streams[len(requests) - 1] + + mock_anthropic_client.beta.messages.create.side_effect = create client = create_test_anthropic_client(mock_anthropic_client) stream = client._inner_get_response( # type: ignore[attr-defined] messages=[Message(role="user", contents=["Think carefully."])], @@ -4009,9 +4240,13 @@ async def events() -> Any: _ = [update async for update in stream] response = await stream.get_final_response() - assert mock_anthropic_client.beta.messages.create.call_count == 1 - assert response.finish_reason == "pause_turn" - assert response.text == "Still working." + assert len(requests) == 2 + assert requests[1]["messages"][1]["content"] == [ + {"type": "redacted_thinking", "data": "opaque"}, + {"type": "text", "text": "Still working."}, + ] + assert response.finish_reason == "stop" + assert [message.text for message in response.messages] == ["Still working.", "Finished."] async def test_anthropic_pause_turn_limit_preserves_finish_reason(monkeypatch: pytest.MonkeyPatch) -> None: