Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions python/packages/foundry_hosting/tests/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
from azure.ai.agentserver.responses.streaming._checkpoint import ResponseCheckpointEvent
from mcp import MCPError
from openai import AsyncOpenAI, DefaultAsyncHttpxClient
from openai.types.responses import ResponseTextDeltaEvent, ResponseTextDoneEvent
from openai.types.responses.response_input_item_param import ResponseInputItemParam
from openai.types.responses.response_usage import ResponseUsage as OpenAIResponseUsage
from pydantic import TypeAdapter
Expand Down Expand Up @@ -7835,9 +7836,34 @@ async def test_non_streaming_emits_mcp_approval_request_and_persists_to_storage(
assert loaded.function_call is not None
assert loaded.function_call.name == "delete_file"

async def test_streaming_emits_mcp_approval_request_and_persists_to_storage(self) -> None:
@pytest.mark.parametrize("with_preamble", [False, True])
async def test_streaming_emits_mcp_approval_request_and_persists_to_storage(self, with_preamble: bool) -> None:
request_content = _make_function_approval_request_content(request_id="apr_streaming")
agent = _make_agent(stream_updates=[AgentResponseUpdate(contents=[request_content], role="assistant")])
updates: list[AgentResponseUpdate] = []
preambles = ("Checking the refund policy.", "The refund requires approval.")
if with_preamble:
client = OpenAIChatClient(model="test-model", api_key="test-key")
for output_index, text in enumerate(preambles):
event_args: dict[str, Any] = {
"content_index": 0,
"item_id": f"message_{output_index}",
"output_index": output_index,
"sequence_number": output_index * 2,
}
for event in (
ResponseTextDeltaEvent(type="response.output_text.delta", delta=text, logprobs=[], **event_args),
ResponseTextDoneEvent(type="response.output_text.done", text=text, logprobs=[], **event_args),
):
update = client._parse_chunk_from_openai(event, {}, {})
updates.append(
AgentResponseUpdate(
contents=update.contents,
message_id=update.message_id,
role="assistant",
)
)
updates.append(AgentResponseUpdate(contents=[request_content], role="assistant"))
agent = _make_agent(stream_updates=updates)
server = _make_server(agent)

resp = await _post(server, stream=True)
Expand All @@ -7848,6 +7874,11 @@ async def test_streaming_emits_mcp_approval_request_and_persists_to_storage(self
assert types[0] == "response.created"
assert types[-1] == "response.completed"

if with_preamble:
assert [event["data"]["text"] for event in events if event["event"] == "response.output_text.done"] == list(
preambles
)

approval_request_id: str | None = None
for e in events:
if e["event"] != "response.output_item.added":
Expand Down
64 changes: 52 additions & 12 deletions python/packages/openai/agent_framework_openai/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1073,8 +1073,28 @@ def _finalize_response_updates(
self._enrich_mcp_search_citations([content for update in updates for content in update.contents])
response = super()._finalize_response_updates(updates, response_format=response_format)
self._mark_completed_computer_calls([content for message in response.messages for content in message.contents])
logprobs: list[Any] = []
message_logprobs: list[list[Any]] = []
current_message_id: str | None = None
current_author_name: str | None = None
current_role: str | None = None
for update in updates:
is_new_message = (
not message_logprobs
or (update.message_id and current_message_id and current_message_id != update.message_id)
or (update.author_name and current_author_name and current_author_name != update.author_name)
or (update.role and current_role != update.role)
)
if is_new_message:
message_logprobs.append([])
current_message_id = None
current_author_name = None
current_role = "assistant"
if update.message_id:
current_message_id = update.message_id
if update.author_name is not None:
current_author_name = update.author_name
if update.role is not None:
current_role = update.role
for content in update.contents:
if content.type != "text":
continue
Expand All @@ -1085,15 +1105,15 @@ def _finalize_response_updates(
default=[],
)
if isinstance(content_logprobs, list):
logprobs.extend(cast("list[Any]", content_logprobs))
if logprobs:
message_logprobs[-1].extend(cast("list[Any]", content_logprobs))
for message, logprobs in zip(response.messages, message_logprobs):
if not logprobs or message.role != "assistant":
continue
assistant_text = next(
(
content
for message in response.messages
if message.role == "assistant"
for content in message.contents
if content.type == "text"
if content.type == "text" and not _is_refusal_text_content(content)
),
None,
)
Expand Down Expand Up @@ -3470,13 +3490,23 @@ def _parse_response_from_openai(
structured_response: BaseModel | None = response.output_parsed if isinstance(response, ParsedResponse) else None # type: ignore[reportUnknownMemberType]

metadata: dict[str, Any] = response.metadata or {}
contents: list[Content] = []
messages: list[Message] = []
local_shell_tool_name = self._get_local_shell_tool_name(options.get("tools"))

def contents_for_message(message_id: str | None = None) -> list[Content]:
if not messages or (message_id and messages[-1].message_id and messages[-1].message_id != message_id):
messages.append(Message(role="assistant", contents=[], message_id=message_id))
elif message_id:
messages[-1].message_id = message_id
return messages[-1].contents

try:
response_outputs = response.output # type: ignore[reportUnknownMemberType]
except AttributeError:
response_outputs = []
for item in response_outputs: # type: ignore[reportUnknownVariableType]
message_id = cast(Any, item).id if item.type == "message" else None
contents = contents_for_message(message_id if isinstance(message_id, str) else None)
match item.type:
# types:
# ParsedResponseOutputMessage[Unknown] |
Expand Down Expand Up @@ -3758,14 +3788,16 @@ def _parse_response_from_openai(
contents.extend(self._shell_item_to_contents(item, local_shell_tool_name))
case _:
logger.debug("Unparsed output of type: %s: %s", item.type, item)
self._mark_completed_computer_calls(contents)
response_message = Message(role="assistant", contents=contents)
if not messages:
messages.append(Message(role="assistant", contents=[]))
all_contents = [content for message in messages for content in message.contents]
self._mark_completed_computer_calls(all_contents)
args: dict[str, Any] = {
"response_id": response.id,
"created_at": datetime.fromtimestamp(response.created_at, tz=timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
),
"messages": response_message,
"messages": messages,
"model": response.model,
"additional_properties": metadata,
"raw_representation": response,
Expand All @@ -3785,8 +3817,7 @@ def _parse_response_from_openai(
if response.status and response.status in ("in_progress", "queued"):
args["continuation_token"] = OpenAIContinuationToken(response_id=response.id)
chat_response = ChatResponse(**args)
if chat_response.messages:
self._enrich_mcp_search_citations(chat_response.messages[0].contents)
self._enrich_mcp_search_citations(all_contents)
return chat_response

def _parse_annotation_from_openai(self, annotation: Any, annotation_index: int) -> Annotation | None:
Expand Down Expand Up @@ -4577,8 +4608,17 @@ def parse_annotations(
if not isinstance(event.type, str) or not event.type.startswith(_AZURE_AI_SEARCH_OUTPUT_EVENT_PREFIX):
logger.debug("Unparsed event of type: %s: %s", event.type, event)

message_id: str | None = None
if any(content.type == "text" for content in contents):
item_id = getattr(event, "item_id", None)
if event.type == "response.output_item.done":
item_id = event.item.id
if isinstance(item_id, str):
message_id = item_id

update = ChatResponseUpdate(
contents=contents,
message_id=message_id,
Comment thread
eavanvalkenburg marked this conversation as resolved.
Resolved
Comment thread
eavanvalkenburg marked this conversation as resolved.
Resolved
conversation_id=conversation_id,
response_id=response_id,
role="assistant",
Expand Down
Loading
Loading