Merge branch 'main' into dev/dotnet_workflow/Enable-HandoffHILReturnToPrevious

This commit is contained in:
Jacob Alber
2026-03-26 05:12:12 -04:00
committed by GitHub
Unverified
16 changed files with 1050 additions and 54 deletions
@@ -313,6 +313,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
self._map_a2a_stream(
a2a_stream,
background=background,
emit_intermediate=stream,
session=provider_session,
session_context=session_context,
),
@@ -327,6 +328,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
a2a_stream: AsyncIterable[A2AStreamItem],
*,
background: bool = False,
emit_intermediate: bool = False,
session: AgentSession | None = None,
session_context: SessionContext | None = None,
) -> AsyncIterable[AgentResponseUpdate]:
@@ -339,6 +341,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
background: When False, in-progress task updates are silently
consumed (the stream keeps iterating until a terminal state).
When True, they are yielded with a continuation token.
emit_intermediate: When True, in-progress status updates that
carry message content are yielded to the caller. Typically
set for streaming callers so non-streaming consumers only
receive terminal task outputs.
session: The agent session for context providers.
session_context: The session context for context providers.
"""
@@ -373,7 +379,11 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
yield update
elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Task):
task, _update_event = item
for update in self._updates_from_task(task, background=background):
for update in self._updates_from_task(
task,
background=background,
emit_intermediate=emit_intermediate,
):
all_updates.append(update)
yield update
else:
@@ -389,15 +399,26 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
# Task helpers
# ------------------------------------------------------------------
def _updates_from_task(self, task: Task, *, background: bool = False) -> list[AgentResponseUpdate]:
def _updates_from_task(
self,
task: Task,
*,
background: bool = False,
emit_intermediate: bool = False,
) -> list[AgentResponseUpdate]:
"""Convert an A2A Task into AgentResponseUpdate(s).
Terminal tasks produce updates from their artifacts/history.
In-progress tasks produce a continuation token update only when
``background=True``; otherwise they are silently skipped so the
caller keeps consuming the stream until completion.
In-progress tasks produce a continuation token update when
``background=True``. When ``emit_intermediate=True`` (typically
set for streaming callers), any message content attached to an
in-progress status update is surfaced; otherwise the update is
silently skipped so the caller keeps consuming the stream until
completion.
"""
if task.status.state in TERMINAL_TASK_STATES:
status = task.status
if status.state in TERMINAL_TASK_STATES:
task_messages = self._parse_messages_from_task(task)
if task_messages:
return [
@@ -412,7 +433,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
]
return [AgentResponseUpdate(contents=[], role="assistant", response_id=task.id, raw_representation=task)]
if background and task.status.state in IN_PROGRESS_TASK_STATES:
if background and status.state in IN_PROGRESS_TASK_STATES:
token = self._build_continuation_token(task)
return [
AgentResponseUpdate(
@@ -424,6 +445,26 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
)
]
# Surface message content from in-progress status updates (e.g. working state)
# Only emitted when the caller opts in (streaming), so non-streaming
# consumers keep receiving only terminal task outputs.
if (
emit_intermediate
and status.state in IN_PROGRESS_TASK_STATES
and status.message is not None
and status.message.parts
):
contents = self._parse_contents_from_a2a(status.message.parts)
if contents:
return [
AgentResponseUpdate(
contents=contents,
role="assistant" if status.message.role == A2ARole.agent else "user",
response_id=task.id,
raw_representation=task,
)
]
return []
@staticmethod
+154 -3
View File
@@ -91,9 +91,18 @@ class MockA2AClient:
task_id: str,
context_id: str = "test-context",
state: TaskState = TaskState.working,
text: str | None = None,
role: A2ARole = A2ARole.agent,
) -> None:
"""Add a mock in-progress Task response (non-terminal)."""
status = TaskStatus(state=state, message=None)
message = None
if text is not None:
message = A2AMessage(
message_id=str(uuid4()),
role=role,
parts=[Part(root=TextPart(text=text))],
)
status = TaskStatus(state=state, message=message)
task = Task(id=task_id, context_id=context_id, status=status)
client_event = (task, None)
self.responses.append(client_event)
@@ -102,9 +111,10 @@ class MockA2AClient:
"""Mock send_message method that yields responses."""
self.call_count += 1
if self.responses:
response = self.responses.pop(0)
# All queued responses are delivered as a single streaming batch per call.
for response in self.responses:
yield response
self.responses.clear()
async def resubscribe(self, request: Any) -> AsyncIterator[Any]:
"""Mock resubscribe method that yields responses."""
@@ -1039,3 +1049,144 @@ async def test_run_with_continuation_token_does_not_require_messages(mock_a2a_cl
# endregion
# region Streaming with in-progress message content
async def test_streaming_working_updates_yield_message_content(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that streaming working updates with status.message yield content."""
mock_a2a_client.add_in_progress_task_response("task-w", context_id="ctx-w", text="Processing step 1...")
mock_a2a_client.add_in_progress_task_response("task-w", context_id="ctx-w", text="Processing step 2...")
mock_a2a_client.add_task_response("task-w", [{"id": "art-w", "content": "Final result"}])
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
updates.append(update)
assert len(updates) == 3
assert updates[0].contents[0].text == "Processing step 1..."
assert updates[1].contents[0].text == "Processing step 2..."
assert updates[2].contents[0].text == "Final result"
async def test_streaming_single_working_update_with_message(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that a single working update with message content is not dropped."""
mock_a2a_client.add_in_progress_task_response("task-s", context_id="ctx-s", text="Thinking...")
mock_a2a_client.add_task_response("task-s", [{"id": "art-s", "content": "Done"}])
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
updates.append(update)
assert len(updates) == 2
assert updates[0].contents[0].text == "Thinking..."
assert updates[0].role == "assistant"
assert updates[1].contents[0].text == "Done"
async def test_streaming_working_update_without_message_is_skipped(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that working updates without status.message are still silently skipped."""
mock_a2a_client.add_in_progress_task_response("task-n", context_id="ctx-n")
mock_a2a_client.add_task_response("task-n", [{"id": "art-n", "content": "Result"}])
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
updates.append(update)
assert len(updates) == 1
assert updates[0].contents[0].text == "Result"
async def test_streaming_working_update_user_role_mapping(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
"""Test that A2ARole.user in status message maps to role='user'."""
mock_a2a_client.add_in_progress_task_response("task-u", context_id="ctx-u", text="User echo", role=A2ARole.user)
mock_a2a_client.add_task_response("task-u", [{"id": "art-u", "content": "Done"}])
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
updates.append(update)
assert len(updates) == 2
assert updates[0].contents[0].text == "User echo"
assert updates[0].role == "user"
async def test_background_with_status_message_yields_continuation_token(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that background=True takes precedence over status message content."""
mock_a2a_client.add_in_progress_task_response("task-bg", context_id="ctx-bg", text="Should be ignored")
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True, background=True):
updates.append(update)
assert len(updates) == 1
assert updates[0].continuation_token is not None
assert updates[0].continuation_token["task_id"] == "task-bg"
assert updates[0].contents == []
async def test_non_streaming_does_not_surface_intermediate_messages(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that run(stream=False) does not include intermediate status messages."""
mock_a2a_client.add_in_progress_task_response("task-ns", context_id="ctx-ns", text="Intermediate")
mock_a2a_client.add_task_response("task-ns", [{"id": "art-ns", "content": "Final"}])
response = await a2a_agent.run("Hello")
assert len(response.messages) == 1
assert response.messages[0].text == "Final"
async def test_terminal_no_artifacts_after_working_with_content(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that a terminal task with no artifacts after working-state messages does not re-emit the working content."""
mock_a2a_client.add_in_progress_task_response("task-t", context_id="ctx-t", text="Working on it...")
# Terminal task with no artifacts and no history
status = TaskStatus(state=TaskState.completed, message=None)
task = Task(id="task-t", context_id="ctx-t", status=status)
mock_a2a_client.responses.append((task, None))
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
updates.append(update)
assert len(updates) == 2
assert updates[0].contents[0].text == "Working on it..."
# Terminal task with no artifacts yields an empty-contents update
assert updates[1].contents == []
async def test_streaming_working_update_with_empty_parts_is_skipped(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Test that a working update with status.message but empty parts list is skipped."""
# Construct a message with an empty parts list (distinct from message=None)
message = A2AMessage(
message_id=str(uuid4()),
role=A2ARole.agent,
parts=[],
)
status = TaskStatus(state=TaskState.working, message=message)
task = Task(id="task-ep", context_id="ctx-ep", status=status)
mock_a2a_client.responses.append((task, None))
mock_a2a_client.add_task_response("task-ep", [{"id": "art-ep", "content": "Result"}])
updates: list[AgentResponseUpdate] = []
async for update in a2a_agent.run("Hello", stream=True):
updates.append(update)
assert len(updates) == 1
assert updates[0].contents[0].text == "Result"
# endregion
@@ -684,6 +684,10 @@ def _build_messages_snapshot(
}
)
# Add reasoning messages so frontends that reconcile state from
# MESSAGES_SNAPSHOT retain reasoning content after streaming ends.
all_messages.extend(flow.reasoning_messages)
return MessagesSnapshotEvent(messages=all_messages) # type: ignore[arg-type]
@@ -1061,7 +1065,9 @@ async def run_agent_stream(
# Emit MessagesSnapshotEvent if we have tool calls or results
# Feature #5: Suppress intermediate snapshots for predictive tools without confirmation
should_emit_snapshot = flow.pending_tool_calls or flow.tool_results or flow.accumulated_text
should_emit_snapshot = (
flow.pending_tool_calls or flow.tool_results or flow.accumulated_text or flow.reasoning_messages
)
if should_emit_snapshot:
# Check if we should suppress for predictive tool
last_tool_name = None
@@ -604,6 +604,10 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Mes
# Handle standard tool result messages early (role="tool") to preserve provider invariants
# This path maps AGUI tool messages to function_result content with the correct tool_call_id
role_str = normalize_agui_role(msg.get("role", "user"))
if role_str == "reasoning":
# Reasoning messages are UI-only state carried in MESSAGES_SNAPSHOT.
# They should not be forwarded to the LLM provider.
continue
if role_str == "tool":
# Prefer explicit tool_call_id fields; fall back to backend fields only if necessary
tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId")
@@ -1020,6 +1024,11 @@ def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dic
elif "toolCallId" not in normalized_msg:
normalized_msg["toolCallId"] = ""
# Normalize encrypted_value to encryptedValue for reasoning messages
if normalized_msg.get("role") == "reasoning" and "encrypted_value" in normalized_msg:
normalized_msg["encryptedValue"] = normalized_msg["encrypted_value"]
del normalized_msg["encrypted_value"]
result.append(normalized_msg)
return result
@@ -126,6 +126,8 @@ class FlowState:
tool_results: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
tool_calls_ended: set[str] = field(default_factory=set) # pyright: ignore[reportUnknownVariableType]
interrupts: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
reasoning_messages: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
accumulated_reasoning: dict[str, str] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
def get_tool_name(self, call_id: str | None) -> str | None:
"""Get tool name by call ID."""
@@ -460,7 +462,7 @@ def _emit_mcp_tool_result(
return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler)
def _emit_text_reasoning(content: Content) -> list[BaseEvent]:
def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> list[BaseEvent]:
"""Emit AG-UI reasoning events for text_reasoning content.
Uses the protocol-defined reasoning event types so that AG-UI consumers
@@ -470,6 +472,10 @@ def _emit_text_reasoning(content: Content) -> list[BaseEvent]:
``content.protected_data`` is present it is emitted as a
``ReasoningEncryptedValueEvent`` so that consumers can persist encrypted
reasoning for state continuity without conflating it with display text.
When *flow* is provided the reasoning message is persisted into
``flow.reasoning_messages`` so that ``_build_messages_snapshot`` can
include it in the final ``MESSAGES_SNAPSHOT``.
"""
text = content.text or ""
if not text and content.protected_data is None:
@@ -498,6 +504,36 @@ def _emit_text_reasoning(content: Content) -> list[BaseEvent]:
events.append(ReasoningEndEvent(message_id=message_id))
# Persist reasoning into flow state for MESSAGES_SNAPSHOT.
# Accumulate reasoning text per message_id, similar to flow.accumulated_text,
# so that incremental deltas build the full reasoning string.
if flow is not None:
if text:
previous_text = flow.accumulated_reasoning.get(message_id, "")
flow.accumulated_reasoning[message_id] = previous_text + text
full_text = flow.accumulated_reasoning.get(message_id, text or "")
# Update existing reasoning entry for this message_id if present; otherwise append a new one.
existing_entry: dict[str, Any] | None = None
for entry in flow.reasoning_messages:
if isinstance(entry, dict) and entry.get("id") == message_id:
existing_entry = entry
break
if existing_entry is None:
reasoning_entry: dict[str, Any] = {
"id": message_id,
"role": "reasoning",
"content": full_text,
}
if content.protected_data is not None:
reasoning_entry["encryptedValue"] = content.protected_data
flow.reasoning_messages.append(reasoning_entry)
else:
existing_entry["content"] = full_text
if content.protected_data is not None:
existing_entry["encryptedValue"] = content.protected_data
return events
@@ -527,6 +563,6 @@ def _emit_content(
if content_type == "mcp_server_tool_result":
return _emit_mcp_tool_result(content, flow, predictive_handler)
if content_type == "text_reasoning":
return _emit_text_reasoning(content)
return _emit_text_reasoning(content, flow)
logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type)
return []
@@ -27,7 +27,7 @@ FRAMEWORK_TO_AGUI_ROLE: dict[str, str] = {
"system": "system",
}
ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool"}
ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool", "reasoning"}
def generate_event_id() -> str:
@@ -82,7 +82,7 @@ def normalize_agui_role(raw_role: Any) -> str:
raw_role: Raw role value from AG-UI message
Returns:
Normalized role string (user, assistant, system, or tool)
Normalized role string (user, assistant, system, tool, or reasoning)
"""
if not isinstance(raw_role, str):
return "user"
@@ -1669,3 +1669,94 @@ def test_agui_fresh_approval_is_still_processed():
assert len(approval_contents) == 1, "Fresh approval should produce function_approval_response"
assert approval_contents[0].approved is True
assert approval_contents[0].function_call.name == "get_datetime"
class TestReasoningRoundTrip:
"""Tests for reasoning message handling in inbound/outbound adapters."""
def test_reasoning_skipped_on_inbound(self):
"""Reasoning messages from prior snapshot are not forwarded to the LLM."""
messages_input = [
{"id": "u1", "role": "user", "content": "Hello"},
{"id": "r1", "role": "reasoning", "content": "Thinking..."},
{"id": "a1", "role": "assistant", "content": "Hi there"},
]
result = agui_messages_to_agent_framework(messages_input)
roles = [m.role if hasattr(m.role, "value") else str(m.role) for m in result]
assert "reasoning" not in roles
assert len(result) == 2
def test_reasoning_preserved_in_snapshot_format(self):
"""Reasoning messages retain their role through snapshot normalization."""
messages_input = [
{"id": "u1", "role": "user", "content": "Hello"},
{"id": "r1", "role": "reasoning", "content": "Thinking about this..."},
{"id": "a1", "role": "assistant", "content": "Answer"},
]
result = agui_messages_to_snapshot_format(messages_input)
reasoning_msgs = [m for m in result if m.get("role") == "reasoning"]
assert len(reasoning_msgs) == 1
assert reasoning_msgs[0]["content"] == "Thinking about this..."
def test_reasoning_with_encrypted_value_in_snapshot_format(self):
"""Reasoning with encryptedValue passes through snapshot normalization."""
messages_input = [
{
"id": "r1",
"role": "reasoning",
"content": "visible",
"encryptedValue": "secret-data",
},
]
result = agui_messages_to_snapshot_format(messages_input)
assert len(result) == 1
assert result[0]["role"] == "reasoning"
assert result[0]["encryptedValue"] == "secret-data"
def test_reasoning_encrypted_value_snake_case_normalized(self):
"""Snake-case encrypted_value is normalized to encryptedValue in snapshot format."""
messages_input = [
{
"id": "r1",
"role": "reasoning",
"content": "visible",
"encrypted_value": "snake-case-data",
},
]
result = agui_messages_to_snapshot_format(messages_input)
assert len(result) == 1
assert result[0]["encryptedValue"] == "snake-case-data"
assert "encrypted_value" not in result[0]
def test_multi_turn_with_reasoning_in_prior_snapshot(self):
"""Second turn with reasoning from prior snapshot does not corrupt messages."""
messages_input = [
{"id": "u1", "role": "user", "content": "First question"},
{"id": "r1", "role": "reasoning", "content": "Prior reasoning"},
{"id": "a1", "role": "assistant", "content": "First answer"},
{"id": "u2", "role": "user", "content": "Follow-up question"},
]
result = agui_messages_to_agent_framework(messages_input)
roles = [m.role if hasattr(m.role, "value") else str(m.role) for m in result]
# Reasoning is filtered out, other messages preserved in order
assert roles == ["user", "assistant", "user"]
# Content not corrupted
texts = []
for m in result:
for c in m.contents or []:
if hasattr(c, "text") and c.text:
texts.append(c.text)
assert "First question" in texts
assert "First answer" in texts
assert "Follow-up question" in texts
assert "Prior reasoning" not in texts
@@ -1346,3 +1346,158 @@ class TestEmitContentMcpRouting:
assert len(events) == 5
assert isinstance(events[0], ReasoningStartEvent)
class TestReasoningInSnapshot:
"""Tests for reasoning message inclusion in MESSAGES_SNAPSHOT."""
def test_reasoning_persisted_to_flow_state(self):
"""_emit_text_reasoning with flow persists reasoning into flow.reasoning_messages."""
flow = FlowState()
content = Content.from_text_reasoning(
id="reason_persist",
text="Let me think step by step.",
)
_emit_text_reasoning(content, flow)
assert len(flow.reasoning_messages) == 1
assert flow.reasoning_messages[0]["id"] == "reason_persist"
assert flow.reasoning_messages[0]["role"] == "reasoning"
assert flow.reasoning_messages[0]["content"] == "Let me think step by step."
assert "encryptedValue" not in flow.reasoning_messages[0]
def test_reasoning_with_encrypted_value_persisted(self):
"""Reasoning with protected_data preserves encryptedValue in flow state."""
flow = FlowState()
content = Content.from_text_reasoning(
id="reason_enc",
text="visible reasoning",
protected_data="encrypted-data-123",
)
_emit_text_reasoning(content, flow)
assert len(flow.reasoning_messages) == 1
assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-data-123"
def test_snapshot_includes_reasoning(self):
"""_build_messages_snapshot includes reasoning messages from flow state."""
from agent_framework_ag_ui._agent_run import _build_messages_snapshot
flow = FlowState()
flow.accumulated_text = "Here is my answer."
flow.reasoning_messages = [
{"id": "r1", "role": "reasoning", "content": "Thinking..."},
]
snapshot = _build_messages_snapshot(flow, [])
roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in snapshot.messages]
assert "reasoning" in roles
def test_snapshot_preserves_reasoning_encrypted_value(self):
"""Snapshot reasoning with encryptedValue is preserved end-to-end."""
from agent_framework_ag_ui._agent_run import _build_messages_snapshot
flow = FlowState()
content = Content.from_text_reasoning(
id="reason_e2e",
text="visible",
protected_data="secret-data",
)
_emit_text_reasoning(content, flow)
text_content = Content.from_text("Final answer.")
_emit_text(text_content, flow)
snapshot = _build_messages_snapshot(flow, [])
reasoning_msgs = [
m
for m in snapshot.messages
if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "reasoning"
]
assert len(reasoning_msgs) == 1
msg = reasoning_msgs[0]
if isinstance(msg, dict):
assert msg["content"] == "visible"
assert msg["encryptedValue"] == "secret-data"
def test_emit_content_routes_reasoning_with_flow(self):
"""_emit_content passes flow to _emit_text_reasoning for persistence."""
flow = FlowState()
content = Content.from_text_reasoning(text="routed reasoning")
_emit_content(content, flow)
assert len(flow.reasoning_messages) == 1
assert flow.reasoning_messages[0]["content"] == "routed reasoning"
def test_reasoning_without_flow_does_not_error(self):
"""Calling _emit_text_reasoning without flow still works (backward compat)."""
content = Content.from_text_reasoning(text="no flow")
events = _emit_text_reasoning(content)
assert len(events) == 5
assert isinstance(events[0], ReasoningStartEvent)
def test_snapshot_reasoning_ordering(self):
"""Reasoning messages appear after assistant text in snapshot."""
from agent_framework_ag_ui._agent_run import _build_messages_snapshot
flow = FlowState()
reasoning_content = Content.from_text_reasoning(id="r1", text="Thinking...")
_emit_text_reasoning(reasoning_content, flow)
text_content = Content.from_text("Answer")
_emit_text(text_content, flow)
snapshot = _build_messages_snapshot(flow, [{"id": "u1", "role": "user", "content": "Hi"}])
# user -> assistant text -> reasoning
assert len(snapshot.messages) == 3
roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in snapshot.messages]
assert roles == ["user", "assistant", "reasoning"]
def test_reasoning_accumulates_incremental_deltas(self):
"""Multiple reasoning deltas with the same id accumulate into one entry."""
flow = FlowState()
content1 = Content.from_text_reasoning(id="reason_inc", text="First ")
content2 = Content.from_text_reasoning(id="reason_inc", text="second ")
content3 = Content.from_text_reasoning(id="reason_inc", text="third.")
_emit_text_reasoning(content1, flow)
_emit_text_reasoning(content2, flow)
_emit_text_reasoning(content3, flow)
assert len(flow.reasoning_messages) == 1
assert flow.reasoning_messages[0]["id"] == "reason_inc"
assert flow.reasoning_messages[0]["content"] == "First second third."
def test_reasoning_accumulates_distinct_message_ids(self):
"""Reasoning entries with different ids are stored separately."""
flow = FlowState()
content_a = Content.from_text_reasoning(id="a", text="alpha")
content_b = Content.from_text_reasoning(id="b", text="beta")
_emit_text_reasoning(content_a, flow)
_emit_text_reasoning(content_b, flow)
assert len(flow.reasoning_messages) == 2
assert flow.reasoning_messages[0]["content"] == "alpha"
assert flow.reasoning_messages[1]["content"] == "beta"
def test_reasoning_encrypted_value_updated_on_later_delta(self):
"""encryptedValue is set even when it arrives with a later delta."""
flow = FlowState()
content1 = Content.from_text_reasoning(id="enc_late", text="part1 ")
content2 = Content.from_text_reasoning(id="enc_late", text="part2", protected_data="encrypted-payload")
_emit_text_reasoning(content1, flow)
_emit_text_reasoning(content2, flow)
assert len(flow.reasoning_messages) == 1
assert flow.reasoning_messages[0]["content"] == "part1 part2"
assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-payload"
@@ -450,6 +450,7 @@ def test_normalize_agui_role_valid():
assert normalize_agui_role("assistant") == "assistant"
assert normalize_agui_role("system") == "system"
assert normalize_agui_role("tool") == "tool"
assert normalize_agui_role("reasoning") == "reasoning"
def test_normalize_agui_role_invalid():
+37 -4
View File
@@ -18,7 +18,11 @@ from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
from opentelemetry import propagate
from ._tools import FunctionTool
from ._types import Content, Message
from ._types import (
ChatOptions,
Content,
Message,
)
from .exceptions import ToolException, ToolExecutionException
if sys.version_info >= (3, 11):
@@ -640,6 +644,7 @@ class MCPTool:
raise ToolException(error_msg, inner_exception=ex) from ex
try:
try:
from mcp import types
from mcp.client.session import ClientSession as runtime_client_session
except ModuleNotFoundError as ex:
await self._safe_close_exit_stack()
@@ -647,6 +652,12 @@ class MCPTool:
"MCP support requires `mcp`. Please install `mcp`.",
inner_exception=ex,
) from ex
sampling_capabilities = None
if self.client is not None:
sampling_capabilities = types.SamplingCapability(
tools=types.SamplingToolsCapability(),
)
session = await self._exit_stack.enter_async_context(
runtime_client_session(
read_stream=transport[0],
@@ -657,6 +668,7 @@ class MCPTool:
message_handler=self.message_handler,
logging_callback=self.logging_callback,
sampling_callback=self.sampling_callback,
sampling_capabilities=sampling_capabilities,
)
)
except Exception as ex:
@@ -733,14 +745,35 @@ class MCPTool:
messages: list[Message] = []
for msg in params.messages:
messages.append(self._parse_message_from_mcp(msg))
options: ChatOptions[None] = {}
if params.systemPrompt is not None:
options["instructions"] = params.systemPrompt
if params.tools is not None:
options["tools"] = [
FunctionTool(
name=tool.name,
description=tool.description or "",
input_model=tool.inputSchema,
)
for tool in params.tools
]
if params.toolChoice is not None and params.toolChoice.mode is not None:
options["tool_choice"] = params.toolChoice.mode
if params.temperature is not None:
options["temperature"] = params.temperature
options["max_tokens"] = params.maxTokens
if params.stopSequences is not None:
options["stop"] = params.stopSequences
try:
response = await self.client.get_response(
messages,
temperature=params.temperature,
max_tokens=params.maxTokens,
stop=params.stopSequences,
options=options or None,
)
except Exception as ex:
logger.debug("Sampling callback error: %s", ex, exc_info=True)
return types.ErrorData(
code=types.INTERNAL_ERROR,
message=f"Failed to get chat message content: {ex}",
+365 -1
View File
@@ -1696,12 +1696,15 @@ async def test_mcp_tool_sampling_callback_chat_client_exception():
params.temperature = None
params.maxTokens = None
params.stopSequences = None
params.systemPrompt = None
params.tools = None
params.toolChoice = None
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.ErrorData)
assert result.code == types.INTERNAL_ERROR
assert "Failed to get chat message content: Chat client error" in result.message
assert "Failed to get chat message content" in result.message
async def test_mcp_tool_sampling_callback_no_valid_content():
@@ -1739,6 +1742,9 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
params.temperature = None
params.maxTokens = None
params.stopSequences = None
params.systemPrompt = None
params.tools = None
params.toolChoice = None
result = await tool.sampling_callback(Mock(), params)
@@ -1757,6 +1763,9 @@ async def test_mcp_tool_sampling_callback_no_response_and_successful_message_cre
params.temperature = None
params.maxTokens = None
params.stopSequences = None
params.systemPrompt = None
params.tools = None
params.toolChoice = None
tool.client.get_response.return_value = None
no_response = await tool.sampling_callback(Mock(), params)
@@ -1787,6 +1796,361 @@ async def test_mcp_tool_logging_callback_logs_at_requested_level() -> None:
mock_log.assert_called_once_with(logging.WARNING, "be careful")
async def test_mcp_tool_sampling_callback_forwards_system_prompt():
"""Test sampling callback passes systemPrompt as instructions in options."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
mock_chat_client = AsyncMock()
mock_response = Mock()
mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])]
mock_response.model_id = "test-model"
mock_chat_client.get_response.return_value = mock_response
tool.client = mock_chat_client
params = Mock()
mock_message = Mock()
mock_message.role = "user"
mock_message.content = Mock()
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = None
params.stopSequences = None
params.systemPrompt = "You are a helpful assistant"
params.tools = None
params.toolChoice = None
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.CreateMessageResult)
call_kwargs = mock_chat_client.get_response.call_args
options = call_kwargs.kwargs.get("options") or {}
assert options.get("instructions") == "You are a helpful assistant"
async def test_mcp_tool_sampling_callback_forwards_tools():
"""Test sampling callback converts MCP tools to FunctionTools and passes them in options."""
from agent_framework import FunctionTool, Message
tool = MCPStdioTool(name="test_tool", command="python")
mock_chat_client = AsyncMock()
mock_response = Mock()
mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])]
mock_response.model_id = "test-model"
mock_chat_client.get_response.return_value = mock_response
tool.client = mock_chat_client
mcp_tool = types.Tool(
name="get_weather",
description="Get weather",
inputSchema={"type": "object", "properties": {"city": {"type": "string"}}},
)
params = Mock()
mock_message = Mock()
mock_message.role = "user"
mock_message.content = Mock()
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = None
params.stopSequences = None
params.systemPrompt = None
params.tools = [mcp_tool]
params.toolChoice = None
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.CreateMessageResult)
call_kwargs = mock_chat_client.get_response.call_args
options = call_kwargs.kwargs.get("options") or {}
tools = options.get("tools")
assert tools is not None
assert len(tools) == 1
assert isinstance(tools[0], FunctionTool)
assert tools[0].name == "get_weather"
assert tools[0].description == "Get weather"
async def test_mcp_tool_sampling_callback_forwards_tool_choice():
"""Test sampling callback passes toolChoice mode in options."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
mock_chat_client = AsyncMock()
mock_response = Mock()
mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])]
mock_response.model_id = "test-model"
mock_chat_client.get_response.return_value = mock_response
tool.client = mock_chat_client
params = Mock()
mock_message = Mock()
mock_message.role = "user"
mock_message.content = Mock()
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = None
params.stopSequences = None
params.systemPrompt = None
params.tools = None
params.toolChoice = types.ToolChoice(mode="required")
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.CreateMessageResult)
call_kwargs = mock_chat_client.get_response.call_args
options = call_kwargs.kwargs.get("options") or {}
assert options.get("tool_choice") == "required"
async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt():
"""Test sampling callback forwards empty string systemPrompt as instructions."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
mock_chat_client = AsyncMock()
mock_response = Mock()
mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])]
mock_response.model_id = "test-model"
mock_chat_client.get_response.return_value = mock_response
tool.client = mock_chat_client
params = Mock()
mock_message = Mock()
mock_message.role = "user"
mock_message.content = Mock()
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = None
params.stopSequences = None
params.systemPrompt = ""
params.tools = None
params.toolChoice = None
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.CreateMessageResult)
call_kwargs = mock_chat_client.get_response.call_args
options = call_kwargs.kwargs.get("options") or {}
assert options.get("instructions") == ""
async def test_mcp_tool_sampling_callback_forwards_empty_tools_list():
"""Test sampling callback forwards empty tools list in options."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
mock_chat_client = AsyncMock()
mock_response = Mock()
mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])]
mock_response.model_id = "test-model"
mock_chat_client.get_response.return_value = mock_response
tool.client = mock_chat_client
params = Mock()
mock_message = Mock()
mock_message.role = "user"
mock_message.content = Mock()
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = None
params.stopSequences = None
params.systemPrompt = None
params.tools = []
params.toolChoice = None
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.CreateMessageResult)
call_kwargs = mock_chat_client.get_response.call_args
options = call_kwargs.kwargs.get("options") or {}
assert options.get("tools") == []
async def test_mcp_tool_sampling_callback_forwards_generation_params_in_options():
"""Test sampling callback passes temperature, max_tokens, and stop in options."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
mock_chat_client = AsyncMock()
mock_response = Mock()
mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])]
mock_response.model_id = "test-model"
mock_chat_client.get_response.return_value = mock_response
tool.client = mock_chat_client
params = Mock()
mock_message = Mock()
mock_message.role = "user"
mock_message.content = Mock()
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = 0.7
params.maxTokens = 256
params.stopSequences = ["STOP"]
params.systemPrompt = None
params.tools = None
params.toolChoice = None
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.CreateMessageResult)
call_kwargs = mock_chat_client.get_response.call_args
options = call_kwargs.kwargs.get("options") or {}
assert options.get("temperature") == 0.7
assert options.get("max_tokens") == 256
assert options.get("stop") == ["STOP"]
# These should not be passed as top-level kwargs
assert "temperature" not in call_kwargs.kwargs
assert "max_tokens" not in call_kwargs.kwargs
assert "stop" not in call_kwargs.kwargs
async def test_mcp_tool_sampling_callback_omits_temperature_when_none():
"""Test sampling callback does not set temperature in options when it is None."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
mock_chat_client = AsyncMock()
mock_response = Mock()
mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])]
mock_response.model_id = "test-model"
mock_chat_client.get_response.return_value = mock_response
tool.client = mock_chat_client
params = Mock()
mock_message = Mock()
mock_message.role = "user"
mock_message.content = Mock()
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = 100
params.stopSequences = None
params.systemPrompt = None
params.tools = None
params.toolChoice = None
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.CreateMessageResult)
call_kwargs = mock_chat_client.get_response.call_args
options = call_kwargs.kwargs.get("options") or {}
assert "temperature" not in options
assert options.get("max_tokens") == 100
assert "stop" not in options
async def test_mcp_tool_sampling_callback_always_passes_max_tokens():
"""Test sampling callback always sets max_tokens in options since maxTokens is a required int field."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
mock_chat_client = AsyncMock()
mock_response = Mock()
mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])]
mock_response.model_id = "test-model"
mock_chat_client.get_response.return_value = mock_response
tool.client = mock_chat_client
params = Mock()
mock_message = Mock()
mock_message.role = "user"
mock_message.content = Mock()
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = 200
params.stopSequences = None
params.systemPrompt = None
params.tools = None
params.toolChoice = None
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.CreateMessageResult)
call_kwargs = mock_chat_client.get_response.call_args
options = call_kwargs.kwargs.get("options") or {}
assert options["max_tokens"] == 200
async def test_connect_sampling_capabilities_with_client():
"""Test connect() passes sampling_capabilities to ClientSession when client is set."""
tool = MCPStdioTool(name="test", command="test-command", load_tools=False, load_prompts=False)
tool.client = Mock()
mock_transport = (Mock(), Mock())
mock_context_manager = Mock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
tool.get_mcp_client = Mock(return_value=mock_context_manager)
with patch("mcp.client.session.ClientSession") as mock_session_class:
mock_session = AsyncMock()
mock_session._request_id = 1
session_cm = AsyncMock()
session_cm.__aenter__ = AsyncMock(return_value=mock_session)
session_cm.__aexit__ = AsyncMock(return_value=None)
mock_session_class.return_value = session_cm
await tool.connect()
call_kwargs = mock_session_class.call_args.kwargs
sampling_caps = call_kwargs.get("sampling_capabilities")
assert sampling_caps is not None
assert isinstance(sampling_caps, types.SamplingCapability)
assert sampling_caps.tools is not None
assert isinstance(sampling_caps.tools, types.SamplingToolsCapability)
async def test_connect_no_sampling_capabilities_without_client():
"""Test connect() does not pass sampling_capabilities when no client is set."""
tool = MCPStdioTool(name="test", command="test-command", load_tools=False, load_prompts=False)
# No client set
mock_transport = (Mock(), Mock())
mock_context_manager = Mock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
tool.get_mcp_client = Mock(return_value=mock_context_manager)
with patch("mcp.client.session.ClientSession") as mock_session_class:
mock_session = AsyncMock()
mock_session._request_id = 1
session_cm = AsyncMock()
session_cm.__aenter__ = AsyncMock(return_value=mock_session)
session_cm.__aexit__ = AsyncMock(return_value=None)
mock_session_class.return_value = session_cm
await tool.connect()
call_kwargs = mock_session_class.call_args.kwargs
assert call_kwargs.get("sampling_capabilities") is None
# Test error handling in connect() method
@@ -1728,7 +1728,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
)
)
case "mcp_call":
call_id = item.id
call_id = getattr(item, "id", None) or getattr(item, "call_id", None) or ""
contents.append(
Content.from_mcp_server_tool_call(
call_id=call_id,
@@ -2118,27 +2118,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
raw_representation=event_item,
)
)
result_output = (
getattr(event_item, "result", None)
or getattr(event_item, "output", None)
or getattr(event_item, "outputs", None)
)
parsed_output: list[Content] | None = None
if result_output:
normalized = ( # pyright: ignore[reportUnknownVariableType]
result_output
if isinstance(result_output, Sequence)
and not isinstance(result_output, (str, bytes, MutableMapping))
else [result_output]
)
parsed_output = [Content.from_dict(output_item) for output_item in normalized] # pyright: ignore[reportArgumentType,reportUnknownVariableType]
contents.append(
Content.from_mcp_server_tool_result(
call_id=call_id,
output=parsed_output,
raw_representation=event_item,
)
)
# Result deferred to response.output_item.done
case "code_interpreter_call": # ResponseOutputCodeInterpreterCall
call_id = getattr(event_item, "call_id", None) or getattr(event_item, "id", None)
outputs: list[Content] = []
@@ -2408,6 +2388,21 @@ class RawOpenAIChatClient( # type: ignore[misc]
)
else:
logger.debug("Unparsed annotation type in streaming: %s", ann_type)
case "response.output_item.done":
done_item = event.item
if getattr(done_item, "type", None) == "mcp_call":
call_id = getattr(done_item, "id", None) or getattr(done_item, "call_id", None) or ""
output_text = getattr(done_item, "output", None)
parsed_output: list[Content] | None = (
[Content.from_text(text=output_text)] if isinstance(output_text, str) else None
)
contents.append(
Content.from_mcp_server_tool_result(
call_id=call_id,
output=parsed_output,
raw_representation=done_item,
)
)
case _:
logger.debug("Unparsed event of type: %s: %s", event.type, event)
@@ -216,7 +216,9 @@ def load_openai_service_settings(
openai_settings["model"] = resolved_model
break
if not openai_settings.get("api_version"):
if api_version is not None:
openai_settings["api_version"] = api_version
else:
resolved_api_version = _get_setting_from_alias(
"AZURE_OPENAI_API_VERSION",
dotenv_values_by_name=dotenv_values_by_name,
@@ -48,6 +48,7 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): #
"OPENAI_AUDIO_TO_TEXT_MODEL_ID",
"OPENAI_TEXT_TO_AUDIO_MODEL_ID",
"OPENAI_REALTIME_MODEL_ID",
"OPENAI_API_VERSION",
"OPENAI_BASE_URL",
"AZURE_OPENAI_ENDPOINT",
"AZURE_OPENAI_BASE_URL",
@@ -101,6 +102,7 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic
"OPENAI_AUDIO_TO_TEXT_MODEL_ID",
"OPENAI_TEXT_TO_AUDIO_MODEL_ID",
"OPENAI_REALTIME_MODEL_ID",
"OPENAI_API_VERSION",
"OPENAI_BASE_URL",
"AZURE_OPENAI_ENDPOINT",
"AZURE_OPENAI_BASE_URL",
@@ -1184,11 +1184,13 @@ def test_parse_response_from_openai_with_mcp_server_tool_result() -> None:
assert result_content.output is not None
def test_parse_chunk_from_openai_with_mcp_call_result() -> None:
"""Test _parse_chunk_from_openai with MCP call output."""
def test_parse_chunk_from_openai_with_mcp_call_added_defers_result() -> None:
"""Test that response.output_item.added for mcp_call emits only the call, not the result.
The result is deferred to response.output_item.done.
"""
client = OpenAIChatClient(model="test-model", api_key="test-key")
# Mock event with MCP call that has output
mock_event = MagicMock()
mock_event.type = "response.output_item.added"
@@ -1199,8 +1201,9 @@ def test_parse_chunk_from_openai_with_mcp_call_result() -> None:
mock_item.name = "fetch_resource"
mock_item.server_label = "ResourceServer"
mock_item.arguments = {"resource_id": "123"}
# Use proper content structure that _parse_content can handle
mock_item.result = [{"type": "text", "text": "test result"}]
mock_item.result = None
mock_item.output = None
mock_item.outputs = None
mock_event.item = mock_item
mock_event.output_index = 0
@@ -1209,18 +1212,124 @@ def test_parse_chunk_from_openai_with_mcp_call_result() -> None:
update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids)
# Should have both call and result in contents
assert len(update.contents) == 2
call_content, result_content = update.contents
# Should have only the call content — result is deferred
assert len(update.contents) == 1
call_content = update.contents[0]
assert call_content.type == "mcp_server_tool_call"
assert call_content.call_id in ["mcp_call_456", "call_456"]
assert call_content.tool_name == "fetch_resource"
# No result should be emitted at this point
result_contents = [c for c in update.contents if c.type == "mcp_server_tool_result"]
assert len(result_contents) == 0
def test_parse_chunk_from_openai_with_mcp_output_item_done() -> None:
"""Test that response.output_item.done for mcp_call emits mcp_server_tool_result with output."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
mock_event = MagicMock()
mock_event.type = "response.output_item.done"
mock_item = MagicMock()
mock_item.type = "mcp_call"
mock_item.id = "mcp_call_456"
mock_item.output = "The weather in Seattle is 72F and sunny."
mock_event.item = mock_item
function_call_ids: dict[int, tuple[str, str]] = {}
update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids)
assert len(update.contents) == 1
result_content = update.contents[0]
assert result_content.type == "mcp_server_tool_result"
assert result_content.call_id in ["mcp_call_456", "call_456"]
# Verify the output was parsed
assert result_content.call_id == "mcp_call_456"
assert result_content.output is not None
assert len(result_content.output) == 1
assert result_content.output[0].text == "The weather in Seattle is 72F and sunny."
assert result_content.raw_representation is mock_item
def test_parse_chunk_from_openai_with_mcp_output_item_done_no_output() -> None:
"""Test that response.output_item.done for mcp_call with no output emits result with None output."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
mock_event = MagicMock()
mock_event.type = "response.output_item.done"
mock_item = MagicMock()
mock_item.type = "mcp_call"
mock_item.id = "mcp_call_789"
mock_item.output = None
mock_event.item = mock_item
function_call_ids: dict[int, tuple[str, str]] = {}
update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids)
assert len(update.contents) == 1
result_content = update.contents[0]
assert result_content.type == "mcp_server_tool_result"
assert result_content.call_id == "mcp_call_789"
assert result_content.output is None
assert result_content.raw_representation is mock_item
def test_parse_chunk_from_openai_with_mcp_output_item_done_call_id_fallback() -> None:
"""Test that response.output_item.done for mcp_call falls back to call_id when id is missing."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
mock_event = MagicMock()
mock_event.type = "response.output_item.done"
mock_item = MagicMock(spec=[])
mock_item.type = "mcp_call"
mock_item.call_id = "mcp_fallback_123"
mock_item.output = "fallback result"
mock_event.item = mock_item
function_call_ids: dict[int, tuple[str, str]] = {}
update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids)
assert len(update.contents) == 1
result_content = update.contents[0]
assert result_content.type == "mcp_server_tool_result"
assert result_content.call_id == "mcp_fallback_123"
assert result_content.output is not None
assert result_content.output[0].text == "fallback result"
assert result_content.raw_representation is mock_item
def test_parse_chunk_from_openai_with_mcp_output_item_done_no_id_fallback() -> None:
"""Test that response.output_item.done for mcp_call falls back to empty string when neither id nor call_id exist."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
mock_event = MagicMock()
mock_event.type = "response.output_item.done"
mock_item = MagicMock(spec=[])
mock_item.type = "mcp_call"
mock_item.output = "some result"
mock_event.item = mock_item
function_call_ids: dict[int, tuple[str, str]] = {}
update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids)
assert len(update.contents) == 1
result_content = update.contents[0]
assert result_content.type == "mcp_server_tool_result"
assert result_content.call_id == ""
assert result_content.output is not None
assert result_content.output[0].text == "some result"
assert result_content.raw_representation is mock_item
def test_prepare_message_for_openai_with_function_approval_response() -> None:
@@ -79,7 +79,8 @@ def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str])
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True)
def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None:
def test_init_uses_default_azure_api_version(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None:
monkeypatch.setenv("OPENAI_API_VERSION", "preview")
client = _create_azure_chat_completion_client()
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"]