From 2697823b3eb4e7cb64e53948d33f7f790f921ea0 Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Thu, 26 Feb 2026 17:24:27 -0500 Subject: [PATCH 1/9] fix: handle thread.message.completed event in Assistants API streaming Previously, `thread.message.completed` events fell through to the catch-all `else` branch and yielded empty `ChatResponseUpdate` objects, silently discarding fully-resolved annotation data (file citations, file paths, and their character-offset regions). This commit adds a dedicated handler for `thread.message.completed` that: - Walks the completed ThreadMessage.content array - Extracts text blocks with their fully-resolved annotations - Maps FileCitationAnnotation and FilePathAnnotation to the framework's Annotation type with proper TextSpanRegion data - Yields a ChatResponseUpdate containing the complete text and annotations Fixes #4322 --- .../openai/_assistants_client.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 42a5e32732..4b3992338e 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -16,8 +16,11 @@ from openai import AsyncOpenAI from openai.types.beta.threads import ( + FileCitationAnnotation, + FilePathAnnotation, ImageURLContentBlockParam, ImageURLParam, + Message as ThreadMessage, MessageContentPartParam, MessageDeltaEvent, Run, @@ -39,12 +42,14 @@ normalize_tools, ) from .._types import ( + Annotation, ChatOptions, ChatResponse, ChatResponseUpdate, Content, Message, ResponseStream, + TextSpanRegion, UsageDetails, ) from ..observability import ChatTelemetryLayer @@ -562,6 +567,66 @@ async def _process_stream_events(self, stream: Any, thread_id: str) -> AsyncIter raw_representation=response.data, response_id=response_id, ) + elif response.event == "thread.message.completed" and isinstance(response.data, ThreadMessage): + # Process completed message to extract fully resolved annotations. + # Delta events may carry partial/empty annotation data; the completed + # message contains the final text with all citation details populated. + completed_contents: list[Content] = [] + for block in response.data.content: + if block.type != "text": + continue + text_content = Content.from_text(block.text.value) + if block.text.annotations: + text_content.annotations = [] + for annotation in block.text.annotations: + if isinstance(annotation, FileCitationAnnotation): + ann: Annotation = Annotation( + type="citation", + additional_properties={ + "text": annotation.text, + }, + raw_representation=annotation, + ) + if annotation.file_citation and annotation.file_citation.file_id: + ann["file_id"] = annotation.file_citation.file_id + if annotation.start_index is not None and annotation.end_index is not None: + ann["annotated_regions"] = [ + TextSpanRegion( + type="text_span", + start_index=annotation.start_index, + end_index=annotation.end_index, + ) + ] + text_content.annotations.append(ann) + elif isinstance(annotation, FilePathAnnotation): + ann = Annotation( + type="citation", + additional_properties={ + "text": annotation.text, + }, + raw_representation=annotation, + ) + if annotation.file_path and annotation.file_path.file_id: + ann["file_id"] = annotation.file_path.file_id + if annotation.start_index is not None and annotation.end_index is not None: + ann["annotated_regions"] = [ + TextSpanRegion( + type="text_span", + start_index=annotation.start_index, + end_index=annotation.end_index, + ) + ] + text_content.annotations.append(ann) + completed_contents.append(text_content) + if completed_contents: + yield ChatResponseUpdate( + role="assistant", + contents=completed_contents, + conversation_id=thread_id, + message_id=response_id, + raw_representation=response.data, + response_id=response_id, + ) elif response.event == "thread.run.requires_action" and isinstance(response.data, Run): contents = self._parse_function_calls_from_assistants(response.data, response_id) if contents: From 2135e29fb9795d4462bd2f00fd7642fc15b26d2e Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Thu, 26 Feb 2026 17:25:10 -0500 Subject: [PATCH 2/9] test: add tests for thread.message.completed annotation handling Tests cover: - File citation annotation extraction - File path annotation extraction - Multiple annotations on a single text block - Text-only messages (no annotations) - Non-text blocks are skipped - Mixed content blocks (text + image) - Conversation ID propagation --- .../test_assistants_message_completed.py | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 python/packages/core/tests/openai/test_assistants_message_completed.py diff --git a/python/packages/core/tests/openai/test_assistants_message_completed.py b/python/packages/core/tests/openai/test_assistants_message_completed.py new file mode 100644 index 0000000000..1a4763f825 --- /dev/null +++ b/python/packages/core/tests/openai/test_assistants_message_completed.py @@ -0,0 +1,246 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for thread.message.completed event handling in Assistants API streaming. + +Validates that _process_stream_events correctly handles thread.message.completed +events, extracting fully-resolved annotations from the completed ThreadMessage. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agent_framework.openai._assistants_client import OpenAIAssistantsClient + + +def _make_stream_event(event: str, data: Any) -> MagicMock: + """Create a mock stream event.""" + mock = MagicMock() + mock.event = event + mock.data = data + return mock + + +def _make_text_block(text_value: str, annotations: list | None = None) -> MagicMock: + """Create a mock TextContentBlock with optional annotations.""" + block = MagicMock() + block.type = "text" + block.text = MagicMock() + block.text.value = text_value + block.text.annotations = annotations or [] + return block + + +def _make_image_block() -> MagicMock: + """Create a mock ImageContentBlock (non-text block).""" + block = MagicMock() + block.type = "image_file" + return block + + +def _make_file_citation_annotation( + text: str = "【4:0†source】", + file_id: str = "file-abc123", + start_index: int = 10, + end_index: int = 24, +) -> MagicMock: + """Create a mock FileCitationAnnotation.""" + from openai.types.beta.threads import FileCitationAnnotation + + annotation = MagicMock(spec=FileCitationAnnotation) + annotation.text = text + annotation.start_index = start_index + annotation.end_index = end_index + annotation.file_citation = MagicMock() + annotation.file_citation.file_id = file_id + annotation.file_citation.quote = None + return annotation + + +def _make_file_path_annotation( + text: str = "sandbox:/file.csv", + file_id: str = "file-xyz789", + start_index: int = 5, + end_index: int = 22, +) -> MagicMock: + """Create a mock FilePathAnnotation.""" + from openai.types.beta.threads import FilePathAnnotation + + annotation = MagicMock(spec=FilePathAnnotation) + annotation.text = text + annotation.start_index = start_index + annotation.end_index = end_index + annotation.file_path = MagicMock() + annotation.file_path.file_id = file_id + return annotation + + +def _make_thread_message(content_blocks: list) -> MagicMock: + """Create a mock ThreadMessage.""" + from openai.types.beta.threads import Message as ThreadMessage + + msg = MagicMock(spec=ThreadMessage) + msg.content = content_blocks + return msg + + +async def _collect_updates(client, stream_events, thread_id="thread_123"): + """Helper to collect ChatResponseUpdate objects from _process_stream_events.""" + + class MockAsyncStream: + def __init__(self, events): + self._events = events + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._events: + raise StopAsyncIteration + return self._events.pop(0) + + mock_stream = MockAsyncStream(list(stream_events)) + results = [] + async for update in client._process_stream_events(mock_stream, thread_id): + results.append(update) + return results + + +class TestMessageCompletedAnnotations: + """Tests for thread.message.completed event handling.""" + + @pytest.fixture + def client(self): + """Create a client instance for testing.""" + with patch.object(OpenAIAssistantsClient, "__init__", lambda self, **kw: None): + c = object.__new__(OpenAIAssistantsClient) + return c + + @pytest.mark.asyncio + async def test_message_completed_with_file_citation(self, client): + """Verify file citation annotations are extracted from completed messages.""" + citation = _make_file_citation_annotation( + text="【4:0†source】", file_id="file-abc123", start_index=10, end_index=24 + ) + text_block = _make_text_block("Some text with a citation【4:0†source】", [citation]) + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + # Should yield exactly one update for the completed message + assert len(updates) == 1 + update = updates[0] + assert update.role == "assistant" + assert len(update.contents) == 1 + + content = update.contents[0] + assert content.text == "Some text with a citation【4:0†source】" + assert content.annotations is not None + assert len(content.annotations) == 1 + + ann = content.annotations[0] + assert ann["type"] == "citation" + assert ann["file_id"] == "file-abc123" + assert ann["annotated_regions"][0]["start_index"] == 10 + assert ann["annotated_regions"][0]["end_index"] == 24 + + @pytest.mark.asyncio + async def test_message_completed_with_file_path(self, client): + """Verify file path annotations are extracted from completed messages.""" + file_path = _make_file_path_annotation( + text="sandbox:/output.csv", file_id="file-xyz789", start_index=0, end_index=19 + ) + text_block = _make_text_block("sandbox:/output.csv", [file_path]) + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + assert len(updates) == 1 + content = updates[0].contents[0] + assert content.annotations is not None + assert len(content.annotations) == 1 + + ann = content.annotations[0] + assert ann["type"] == "citation" + assert ann["file_id"] == "file-xyz789" + assert ann["annotated_regions"][0]["start_index"] == 0 + assert ann["annotated_regions"][0]["end_index"] == 19 + + @pytest.mark.asyncio + async def test_message_completed_multiple_annotations(self, client): + """Verify multiple annotations on a single text block are all captured.""" + cit1 = _make_file_citation_annotation(text="【1†src】", file_id="file-a", start_index=5, end_index=12) + cit2 = _make_file_citation_annotation(text="【2†src】", file_id="file-b", start_index=20, end_index=27) + text_block = _make_text_block("Hello【1†src】world【2†src】", [cit1, cit2]) + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + assert len(updates) == 1 + assert len(updates[0].contents[0].annotations) == 2 + assert updates[0].contents[0].annotations[0]["file_id"] == "file-a" + assert updates[0].contents[0].annotations[1]["file_id"] == "file-b" + + @pytest.mark.asyncio + async def test_message_completed_no_annotations(self, client): + """Verify text-only completed messages produce content without annotations.""" + text_block = _make_text_block("Plain text response") + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + assert len(updates) == 1 + content = updates[0].contents[0] + assert content.text == "Plain text response" + assert content.annotations is None or len(content.annotations) == 0 + + @pytest.mark.asyncio + async def test_message_completed_skips_non_text_blocks(self, client): + """Verify non-text content blocks (e.g., image_file) are skipped.""" + image_block = _make_image_block() + msg = _make_thread_message([image_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + # No text blocks → no update yielded + assert len(updates) == 0 + + @pytest.mark.asyncio + async def test_message_completed_mixed_blocks(self, client): + """Verify only text blocks are processed in mixed-content messages.""" + text_block = _make_text_block("Text content here") + image_block = _make_image_block() + msg = _make_thread_message([image_block, text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + assert len(updates) == 1 + assert len(updates[0].contents) == 1 + assert updates[0].contents[0].text == "Text content here" + + @pytest.mark.asyncio + async def test_message_completed_conversation_id_preserved(self, client): + """Verify the thread_id is correctly propagated as conversation_id.""" + text_block = _make_text_block("Response text") + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events, thread_id="thread_custom_456") + + assert len(updates) == 1 + assert updates[0].conversation_id == "thread_custom_456" From dc060ee7c8895f1f93e134ecf424de2b3003f358 Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Thu, 26 Feb 2026 17:40:46 -0500 Subject: [PATCH 3/9] fix: address Copilot review - add quote field and log unrecognized annotations - Include `quote` from `annotation.file_citation.quote` in `additional_properties` for FileCitationAnnotation, preserving the exact cited text snippet from the source file - Add `else` clause to log unrecognized annotation types at debug level, consistent with the pattern in `_responses_client.py` - Add `import logging` and module-level logger --- .../openai/_assistants_client.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 4b3992338e..0d71471811 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import sys from collections.abc import ( AsyncIterable, @@ -73,6 +74,8 @@ if TYPE_CHECKING: from .._middleware import MiddlewareTypes +logger = logging.getLogger(__name__) + # region OpenAI Assistants Options TypedDict @@ -580,11 +583,14 @@ async def _process_stream_events(self, stream: Any, thread_id: str) -> AsyncIter text_content.annotations = [] for annotation in block.text.annotations: if isinstance(annotation, FileCitationAnnotation): + props: dict[str, Any] = { + "text": annotation.text, + } + if annotation.file_citation and annotation.file_citation.quote: + props["quote"] = annotation.file_citation.quote ann: Annotation = Annotation( type="citation", - additional_properties={ - "text": annotation.text, - }, + additional_properties=props, raw_representation=annotation, ) if annotation.file_citation and annotation.file_citation.file_id: @@ -617,6 +623,11 @@ async def _process_stream_events(self, stream: Any, thread_id: str) -> AsyncIter ) ] text_content.annotations.append(ann) + else: + logger.debug( + "Unhandled annotation type in thread.message.completed: %s", + type(annotation).__name__, + ) completed_contents.append(text_content) if completed_contents: yield ChatResponseUpdate( From 2a6b80a25ffc8ddac06c5adcd9e1aeb3af19bfc2 Mon Sep 17 00:00:00 2001 From: "L. Elaine Dazzio" Date: Thu, 26 Feb 2026 17:41:44 -0500 Subject: [PATCH 4/9] test: add coverage for quote field and unrecognized annotation logging - test_message_completed_with_file_citation_quote: verifies quote is included in additional_properties - test_message_completed_with_file_citation_no_quote: verifies quote is omitted when None - test_message_completed_unrecognized_annotation_logged: verifies unknown annotation types are logged at debug level and skipped --- .../test_assistants_message_completed.py | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/python/packages/core/tests/openai/test_assistants_message_completed.py b/python/packages/core/tests/openai/test_assistants_message_completed.py index 1a4763f825..335332206a 100644 --- a/python/packages/core/tests/openai/test_assistants_message_completed.py +++ b/python/packages/core/tests/openai/test_assistants_message_completed.py @@ -8,6 +8,7 @@ from __future__ import annotations +import logging from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -46,6 +47,7 @@ def _make_file_citation_annotation( file_id: str = "file-abc123", start_index: int = 10, end_index: int = 24, + quote: str | None = None, ) -> MagicMock: """Create a mock FileCitationAnnotation.""" from openai.types.beta.threads import FileCitationAnnotation @@ -56,7 +58,7 @@ def _make_file_citation_annotation( annotation.end_index = end_index annotation.file_citation = MagicMock() annotation.file_citation.file_id = file_id - annotation.file_citation.quote = None + annotation.file_citation.quote = quote return annotation @@ -78,6 +80,13 @@ def _make_file_path_annotation( return annotation +def _make_unknown_annotation() -> MagicMock: + """Create a mock annotation of an unrecognized type.""" + annotation = MagicMock() + annotation.__class__.__name__ = "FutureAnnotationType" + return annotation + + def _make_thread_message(content_blocks: list) -> MagicMock: """Create a mock ThreadMessage.""" from openai.types.beta.threads import Message as ThreadMessage @@ -154,6 +163,42 @@ async def test_message_completed_with_file_citation(self, client): assert ann["annotated_regions"][0]["start_index"] == 10 assert ann["annotated_regions"][0]["end_index"] == 24 + @pytest.mark.asyncio + async def test_message_completed_with_file_citation_quote(self, client): + """Verify the quote field from file_citation is included in additional_properties.""" + citation = _make_file_citation_annotation( + text="【4:0†source】", + file_id="file-abc123", + start_index=10, + end_index=24, + quote="The exact quoted text from the source document.", + ) + text_block = _make_text_block("Some text【4:0†source】", [citation]) + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + assert len(updates) == 1 + ann = updates[0].contents[0].annotations[0] + assert ann["additional_properties"]["quote"] == "The exact quoted text from the source document." + + @pytest.mark.asyncio + async def test_message_completed_with_file_citation_no_quote(self, client): + """Verify annotations work when quote is None (not all citations have quotes).""" + citation = _make_file_citation_annotation( + text="【4:0†source】", file_id="file-abc123", start_index=10, end_index=24, quote=None + ) + text_block = _make_text_block("Some text【4:0†source】", [citation]) + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + assert len(updates) == 1 + ann = updates[0].contents[0].annotations[0] + assert "quote" not in ann["additional_properties"] + @pytest.mark.asyncio async def test_message_completed_with_file_path(self, client): """Verify file path annotations are extracted from completed messages.""" @@ -244,3 +289,23 @@ async def test_message_completed_conversation_id_preserved(self, client): assert len(updates) == 1 assert updates[0].conversation_id == "thread_custom_456" + + @pytest.mark.asyncio + async def test_message_completed_unrecognized_annotation_logged(self, client, caplog): + """Verify unrecognized annotation types are logged at debug level and skipped.""" + unknown_ann = _make_unknown_annotation() + citation = _make_file_citation_annotation(text="【1†src】", file_id="file-a", start_index=0, end_index=7) + text_block = _make_text_block("Text【1†src】", [unknown_ann, citation]) + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + with caplog.at_level(logging.DEBUG, logger="agent_framework.openai._assistants_client"): + updates = await _collect_updates(client, events) + + # The known citation should still be processed + assert len(updates) == 1 + assert len(updates[0].contents[0].annotations) == 1 + assert updates[0].contents[0].annotations[0]["file_id"] == "file-a" + + # The unrecognized annotation should have been logged + assert any("Unhandled annotation type" in record.message for record in caplog.records) From ef9bcbf87ed9a10dbf2ec5b795f3204cc33011c8 Mon Sep 17 00:00:00 2001 From: LEDazzio01 <170764058+LEDazzio01@users.noreply.github.com> Date: Sun, 1 Mar 2026 18:57:13 -0500 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20address=20reviewer=20nits=20?= =?UTF-8?q?=E2=80=94=20logger=20name=20convention=20+=20annotation=20type?= =?UTF-8?q?=20string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @giles17's review: - Use logging.getLogger('agent_framework.openai') to match module convention - Simplify debug message to use annotation.type instead of type().__name__ --- .../core/agent_framework/openai/_assistants_client.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 13c3f8c6bf..2622632994 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -76,7 +76,7 @@ if TYPE_CHECKING: from .._middleware import MiddlewareTypes -logger = logging.getLogger(__name__) +logger = logging.getLogger("agent_framework.openai") # region OpenAI Assistants Options TypedDict @@ -670,10 +670,7 @@ async def _process_stream_events(self, stream: Any, thread_id: str) -> AsyncIter ] text_content.annotations.append(ann) else: - logger.debug( - "Unhandled annotation type in thread.message.completed: %s", - type(annotation).__name__, - ) + logger.debug("Unparsed annotation type: %s", annotation.type) completed_contents.append(text_content) if completed_contents: yield ChatResponseUpdate( From 7565839ff0ef965337446cf9c2d193b9550cc986 Mon Sep 17 00:00:00 2001 From: LEDazzio01 <170764058+LEDazzio01@users.noreply.github.com> Date: Sun, 1 Mar 2026 19:05:43 -0500 Subject: [PATCH 6/9] refactor: move message.completed tests into consolidated test file Per @giles17's review: moved all tests from test_assistants_message_completed.py into test_openai_assistants_client.py and deleted the standalone file. --- .../test_assistants_message_completed.py | 311 ------------------ .../openai/test_openai_assistants_client.py | 306 ++++++++++++++++- 2 files changed, 304 insertions(+), 313 deletions(-) delete mode 100644 python/packages/core/tests/openai/test_assistants_message_completed.py diff --git a/python/packages/core/tests/openai/test_assistants_message_completed.py b/python/packages/core/tests/openai/test_assistants_message_completed.py deleted file mode 100644 index 335332206a..0000000000 --- a/python/packages/core/tests/openai/test_assistants_message_completed.py +++ /dev/null @@ -1,311 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Tests for thread.message.completed event handling in Assistants API streaming. - -Validates that _process_stream_events correctly handles thread.message.completed -events, extracting fully-resolved annotations from the completed ThreadMessage. -""" - -from __future__ import annotations - -import logging -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from agent_framework.openai._assistants_client import OpenAIAssistantsClient - - -def _make_stream_event(event: str, data: Any) -> MagicMock: - """Create a mock stream event.""" - mock = MagicMock() - mock.event = event - mock.data = data - return mock - - -def _make_text_block(text_value: str, annotations: list | None = None) -> MagicMock: - """Create a mock TextContentBlock with optional annotations.""" - block = MagicMock() - block.type = "text" - block.text = MagicMock() - block.text.value = text_value - block.text.annotations = annotations or [] - return block - - -def _make_image_block() -> MagicMock: - """Create a mock ImageContentBlock (non-text block).""" - block = MagicMock() - block.type = "image_file" - return block - - -def _make_file_citation_annotation( - text: str = "【4:0†source】", - file_id: str = "file-abc123", - start_index: int = 10, - end_index: int = 24, - quote: str | None = None, -) -> MagicMock: - """Create a mock FileCitationAnnotation.""" - from openai.types.beta.threads import FileCitationAnnotation - - annotation = MagicMock(spec=FileCitationAnnotation) - annotation.text = text - annotation.start_index = start_index - annotation.end_index = end_index - annotation.file_citation = MagicMock() - annotation.file_citation.file_id = file_id - annotation.file_citation.quote = quote - return annotation - - -def _make_file_path_annotation( - text: str = "sandbox:/file.csv", - file_id: str = "file-xyz789", - start_index: int = 5, - end_index: int = 22, -) -> MagicMock: - """Create a mock FilePathAnnotation.""" - from openai.types.beta.threads import FilePathAnnotation - - annotation = MagicMock(spec=FilePathAnnotation) - annotation.text = text - annotation.start_index = start_index - annotation.end_index = end_index - annotation.file_path = MagicMock() - annotation.file_path.file_id = file_id - return annotation - - -def _make_unknown_annotation() -> MagicMock: - """Create a mock annotation of an unrecognized type.""" - annotation = MagicMock() - annotation.__class__.__name__ = "FutureAnnotationType" - return annotation - - -def _make_thread_message(content_blocks: list) -> MagicMock: - """Create a mock ThreadMessage.""" - from openai.types.beta.threads import Message as ThreadMessage - - msg = MagicMock(spec=ThreadMessage) - msg.content = content_blocks - return msg - - -async def _collect_updates(client, stream_events, thread_id="thread_123"): - """Helper to collect ChatResponseUpdate objects from _process_stream_events.""" - - class MockAsyncStream: - def __init__(self, events): - self._events = events - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - pass - - def __aiter__(self): - return self - - async def __anext__(self): - if not self._events: - raise StopAsyncIteration - return self._events.pop(0) - - mock_stream = MockAsyncStream(list(stream_events)) - results = [] - async for update in client._process_stream_events(mock_stream, thread_id): - results.append(update) - return results - - -class TestMessageCompletedAnnotations: - """Tests for thread.message.completed event handling.""" - - @pytest.fixture - def client(self): - """Create a client instance for testing.""" - with patch.object(OpenAIAssistantsClient, "__init__", lambda self, **kw: None): - c = object.__new__(OpenAIAssistantsClient) - return c - - @pytest.mark.asyncio - async def test_message_completed_with_file_citation(self, client): - """Verify file citation annotations are extracted from completed messages.""" - citation = _make_file_citation_annotation( - text="【4:0†source】", file_id="file-abc123", start_index=10, end_index=24 - ) - text_block = _make_text_block("Some text with a citation【4:0†source】", [citation]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - # Should yield exactly one update for the completed message - assert len(updates) == 1 - update = updates[0] - assert update.role == "assistant" - assert len(update.contents) == 1 - - content = update.contents[0] - assert content.text == "Some text with a citation【4:0†source】" - assert content.annotations is not None - assert len(content.annotations) == 1 - - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["file_id"] == "file-abc123" - assert ann["annotated_regions"][0]["start_index"] == 10 - assert ann["annotated_regions"][0]["end_index"] == 24 - - @pytest.mark.asyncio - async def test_message_completed_with_file_citation_quote(self, client): - """Verify the quote field from file_citation is included in additional_properties.""" - citation = _make_file_citation_annotation( - text="【4:0†source】", - file_id="file-abc123", - start_index=10, - end_index=24, - quote="The exact quoted text from the source document.", - ) - text_block = _make_text_block("Some text【4:0†source】", [citation]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - ann = updates[0].contents[0].annotations[0] - assert ann["additional_properties"]["quote"] == "The exact quoted text from the source document." - - @pytest.mark.asyncio - async def test_message_completed_with_file_citation_no_quote(self, client): - """Verify annotations work when quote is None (not all citations have quotes).""" - citation = _make_file_citation_annotation( - text="【4:0†source】", file_id="file-abc123", start_index=10, end_index=24, quote=None - ) - text_block = _make_text_block("Some text【4:0†source】", [citation]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - ann = updates[0].contents[0].annotations[0] - assert "quote" not in ann["additional_properties"] - - @pytest.mark.asyncio - async def test_message_completed_with_file_path(self, client): - """Verify file path annotations are extracted from completed messages.""" - file_path = _make_file_path_annotation( - text="sandbox:/output.csv", file_id="file-xyz789", start_index=0, end_index=19 - ) - text_block = _make_text_block("sandbox:/output.csv", [file_path]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - content = updates[0].contents[0] - assert content.annotations is not None - assert len(content.annotations) == 1 - - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["file_id"] == "file-xyz789" - assert ann["annotated_regions"][0]["start_index"] == 0 - assert ann["annotated_regions"][0]["end_index"] == 19 - - @pytest.mark.asyncio - async def test_message_completed_multiple_annotations(self, client): - """Verify multiple annotations on a single text block are all captured.""" - cit1 = _make_file_citation_annotation(text="【1†src】", file_id="file-a", start_index=5, end_index=12) - cit2 = _make_file_citation_annotation(text="【2†src】", file_id="file-b", start_index=20, end_index=27) - text_block = _make_text_block("Hello【1†src】world【2†src】", [cit1, cit2]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - assert len(updates[0].contents[0].annotations) == 2 - assert updates[0].contents[0].annotations[0]["file_id"] == "file-a" - assert updates[0].contents[0].annotations[1]["file_id"] == "file-b" - - @pytest.mark.asyncio - async def test_message_completed_no_annotations(self, client): - """Verify text-only completed messages produce content without annotations.""" - text_block = _make_text_block("Plain text response") - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - content = updates[0].contents[0] - assert content.text == "Plain text response" - assert content.annotations is None or len(content.annotations) == 0 - - @pytest.mark.asyncio - async def test_message_completed_skips_non_text_blocks(self, client): - """Verify non-text content blocks (e.g., image_file) are skipped.""" - image_block = _make_image_block() - msg = _make_thread_message([image_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - # No text blocks → no update yielded - assert len(updates) == 0 - - @pytest.mark.asyncio - async def test_message_completed_mixed_blocks(self, client): - """Verify only text blocks are processed in mixed-content messages.""" - text_block = _make_text_block("Text content here") - image_block = _make_image_block() - msg = _make_thread_message([image_block, text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - assert len(updates[0].contents) == 1 - assert updates[0].contents[0].text == "Text content here" - - @pytest.mark.asyncio - async def test_message_completed_conversation_id_preserved(self, client): - """Verify the thread_id is correctly propagated as conversation_id.""" - text_block = _make_text_block("Response text") - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events, thread_id="thread_custom_456") - - assert len(updates) == 1 - assert updates[0].conversation_id == "thread_custom_456" - - @pytest.mark.asyncio - async def test_message_completed_unrecognized_annotation_logged(self, client, caplog): - """Verify unrecognized annotation types are logged at debug level and skipped.""" - unknown_ann = _make_unknown_annotation() - citation = _make_file_citation_annotation(text="【1†src】", file_id="file-a", start_index=0, end_index=7) - text_block = _make_text_block("Text【1†src】", [unknown_ann, citation]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - with caplog.at_level(logging.DEBUG, logger="agent_framework.openai._assistants_client"): - updates = await _collect_updates(client, events) - - # The known citation should still be processed - assert len(updates) == 1 - assert len(updates[0].contents[0].annotations) == 1 - assert updates[0].contents[0].annotations[0]["file_id"] == "file-a" - - # The unrecognized annotation should have been logged - assert any("Unhandled annotation type" in record.message for record in caplog.records) diff --git a/python/packages/core/tests/openai/test_openai_assistants_client.py b/python/packages/core/tests/openai/test_openai_assistants_client.py index 8f39573006..8449eb8ca3 100644 --- a/python/packages/core/tests/openai/test_openai_assistants_client.py +++ b/python/packages/core/tests/openai/test_openai_assistants_client.py @@ -1,12 +1,20 @@ # Copyright (c) Microsoft. All rights reserved. import json +import logging import os from typing import Annotated, Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest -from openai.types.beta.threads import MessageDeltaEvent, Run, TextDeltaBlock +from openai.types.beta.threads import ( + FileCitationAnnotation, + FilePathAnnotation, + Message as ThreadMessage, + MessageDeltaEvent, + Run, + TextDeltaBlock, +) from openai.types.beta.threads.file_citation_delta_annotation import FileCitationDeltaAnnotation from openai.types.beta.threads.file_path_delta_annotation import FilePathDeltaAnnotation from openai.types.beta.threads.runs import RunStep @@ -1566,3 +1574,297 @@ async def get_api_key() -> str: assert client.model_id == "gpt-4o" # OpenAI SDK now manages callable API keys internally assert client.client is not None + + +# region thread.message.completed helpers + + +def _make_stream_event(event: str, data: Any) -> MagicMock: + """Create a mock stream event.""" + mock = MagicMock() + mock.event = event + mock.data = data + return mock + + +def _make_text_block(text_value: str, annotations: list | None = None) -> MagicMock: + """Create a mock TextContentBlock with optional annotations.""" + block = MagicMock() + block.type = "text" + block.text = MagicMock() + block.text.value = text_value + block.text.annotations = annotations or [] + return block + + +def _make_image_block() -> MagicMock: + """Create a mock ImageContentBlock (non-text block).""" + block = MagicMock() + block.type = "image_file" + return block + + +def _make_file_citation_annotation( + text: str = "【4:0†source】", + file_id: str = "file-abc123", + start_index: int = 10, + end_index: int = 24, + quote: str | None = None, +) -> MagicMock: + """Create a mock FileCitationAnnotation.""" + annotation = MagicMock(spec=FileCitationAnnotation) + annotation.text = text + annotation.start_index = start_index + annotation.end_index = end_index + annotation.file_citation = MagicMock() + annotation.file_citation.file_id = file_id + annotation.file_citation.quote = quote + return annotation + + +def _make_file_path_annotation( + text: str = "sandbox:/file.csv", + file_id: str = "file-xyz789", + start_index: int = 5, + end_index: int = 22, +) -> MagicMock: + """Create a mock FilePathAnnotation.""" + annotation = MagicMock(spec=FilePathAnnotation) + annotation.text = text + annotation.start_index = start_index + annotation.end_index = end_index + annotation.file_path = MagicMock() + annotation.file_path.file_id = file_id + return annotation + + +def _make_unknown_annotation() -> MagicMock: + """Create a mock annotation of an unrecognized type.""" + annotation = MagicMock() + annotation.__class__.__name__ = "FutureAnnotationType" + return annotation + + +def _make_thread_message(content_blocks: list) -> MagicMock: + """Create a mock ThreadMessage.""" + msg = MagicMock(spec=ThreadMessage) + msg.content = content_blocks + return msg + + +async def _collect_updates(client, stream_events, thread_id="thread_123"): + """Helper to collect ChatResponseUpdate objects from _process_stream_events.""" + + class MockAsyncStream: + def __init__(self, events): + self._events = events + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._events: + raise StopAsyncIteration + return self._events.pop(0) + + mock_stream = MockAsyncStream(list(stream_events)) + results = [] + async for update in client._process_stream_events(mock_stream, thread_id): + results.append(update) + return results + + +# endregion + + +class TestMessageCompletedAnnotations: + """Tests for thread.message.completed event handling.""" + + @pytest.fixture + def client(self): + """Create a client instance for testing.""" + with patch.object(OpenAIAssistantsClient, "__init__", lambda self, **kw: None): + c = object.__new__(OpenAIAssistantsClient) + return c + + @pytest.mark.asyncio + async def test_message_completed_with_file_citation(self, client): + """Verify file citation annotations are extracted from completed messages.""" + citation = _make_file_citation_annotation( + text="【4:0†source】", file_id="file-abc123", start_index=10, end_index=24 + ) + text_block = _make_text_block("Some text with a citation【4:0†source】", [citation]) + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + # Should yield exactly one update for the completed message + assert len(updates) == 1 + update = updates[0] + assert update.role == "assistant" + assert len(update.contents) == 1 + + content = update.contents[0] + assert content.text == "Some text with a citation【4:0†source】" + assert content.annotations is not None + assert len(content.annotations) == 1 + + ann = content.annotations[0] + assert ann["type"] == "citation" + assert ann["file_id"] == "file-abc123" + assert ann["annotated_regions"][0]["start_index"] == 10 + assert ann["annotated_regions"][0]["end_index"] == 24 + + @pytest.mark.asyncio + async def test_message_completed_with_file_citation_quote(self, client): + """Verify the quote field from file_citation is included in additional_properties.""" + citation = _make_file_citation_annotation( + text="【4:0†source】", + file_id="file-abc123", + start_index=10, + end_index=24, + quote="The exact quoted text from the source document.", + ) + text_block = _make_text_block("Some text【4:0†source】", [citation]) + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + assert len(updates) == 1 + ann = updates[0].contents[0].annotations[0] + assert ann["additional_properties"]["quote"] == "The exact quoted text from the source document." + + @pytest.mark.asyncio + async def test_message_completed_with_file_citation_no_quote(self, client): + """Verify annotations work when quote is None (not all citations have quotes).""" + citation = _make_file_citation_annotation( + text="【4:0†source】", file_id="file-abc123", start_index=10, end_index=24, quote=None + ) + text_block = _make_text_block("Some text【4:0†source】", [citation]) + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + assert len(updates) == 1 + ann = updates[0].contents[0].annotations[0] + assert "quote" not in ann["additional_properties"] + + @pytest.mark.asyncio + async def test_message_completed_with_file_path(self, client): + """Verify file path annotations are extracted from completed messages.""" + file_path = _make_file_path_annotation( + text="sandbox:/output.csv", file_id="file-xyz789", start_index=0, end_index=19 + ) + text_block = _make_text_block("sandbox:/output.csv", [file_path]) + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + assert len(updates) == 1 + content = updates[0].contents[0] + assert content.annotations is not None + assert len(content.annotations) == 1 + + ann = content.annotations[0] + assert ann["type"] == "citation" + assert ann["file_id"] == "file-xyz789" + assert ann["annotated_regions"][0]["start_index"] == 0 + assert ann["annotated_regions"][0]["end_index"] == 19 + + @pytest.mark.asyncio + async def test_message_completed_multiple_annotations(self, client): + """Verify multiple annotations on a single text block are all captured.""" + cit1 = _make_file_citation_annotation(text="【1†src】", file_id="file-a", start_index=5, end_index=12) + cit2 = _make_file_citation_annotation(text="【2†src】", file_id="file-b", start_index=20, end_index=27) + text_block = _make_text_block("Hello【1†src】world【2†src】", [cit1, cit2]) + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + assert len(updates) == 1 + assert len(updates[0].contents[0].annotations) == 2 + assert updates[0].contents[0].annotations[0]["file_id"] == "file-a" + assert updates[0].contents[0].annotations[1]["file_id"] == "file-b" + + @pytest.mark.asyncio + async def test_message_completed_no_annotations(self, client): + """Verify text-only completed messages produce content without annotations.""" + text_block = _make_text_block("Plain text response") + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + assert len(updates) == 1 + content = updates[0].contents[0] + assert content.text == "Plain text response" + assert content.annotations is None or len(content.annotations) == 0 + + @pytest.mark.asyncio + async def test_message_completed_skips_non_text_blocks(self, client): + """Verify non-text content blocks (e.g., image_file) are skipped.""" + image_block = _make_image_block() + msg = _make_thread_message([image_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + # No text blocks → no update yielded + assert len(updates) == 0 + + @pytest.mark.asyncio + async def test_message_completed_mixed_blocks(self, client): + """Verify only text blocks are processed in mixed-content messages.""" + text_block = _make_text_block("Text content here") + image_block = _make_image_block() + msg = _make_thread_message([image_block, text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events) + + assert len(updates) == 1 + assert len(updates[0].contents) == 1 + assert updates[0].contents[0].text == "Text content here" + + @pytest.mark.asyncio + async def test_message_completed_conversation_id_preserved(self, client): + """Verify the thread_id is correctly propagated as conversation_id.""" + text_block = _make_text_block("Response text") + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + updates = await _collect_updates(client, events, thread_id="thread_custom_456") + + assert len(updates) == 1 + assert updates[0].conversation_id == "thread_custom_456" + + @pytest.mark.asyncio + async def test_message_completed_unrecognized_annotation_logged(self, client, caplog): + """Verify unrecognized annotation types are logged at debug level and skipped.""" + unknown_ann = _make_unknown_annotation() + citation = _make_file_citation_annotation(text="【1†src】", file_id="file-a", start_index=0, end_index=7) + text_block = _make_text_block("Text【1†src】", [unknown_ann, citation]) + msg = _make_thread_message([text_block]) + + events = [_make_stream_event("thread.message.completed", msg)] + with caplog.at_level(logging.DEBUG, logger="agent_framework.openai"): + updates = await _collect_updates(client, events) + + # The known citation should still be processed + assert len(updates) == 1 + assert len(updates[0].contents[0].annotations) == 1 + assert updates[0].contents[0].annotations[0]["file_id"] == "file-a" + + # The unrecognized annotation should have been logged + assert any("Unparsed annotation type" in record.message for record in caplog.records) From 1592a38b7333813f583149c00e6194748824c5c0 Mon Sep 17 00:00:00 2001 From: LEDazzio01 <170764058+LEDazzio01@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:09:30 -0500 Subject: [PATCH 7/9] fix: resolve mypy no-redef and ruff RET504 lint errors - Remove duplicate type annotation for 'ann' variable (no-redef) - Return directly from fixture instead of unnecessary assignment (RET504) --- .../openai/_assistants_client.py | 8 +++-- .../openai/test_openai_assistants_client.py | 30 +++++++++---------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 2622632994..6f565291af 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -18,18 +18,20 @@ from openai import AsyncOpenAI from openai.types.beta.threads import ( FileCitationAnnotation, - FilePathAnnotation, FileCitationDeltaAnnotation, + FilePathAnnotation, FilePathDeltaAnnotation, ImageURLContentBlockParam, ImageURLParam, - Message as ThreadMessage, MessageContentPartParam, MessageDeltaEvent, Run, TextContentBlockParam, TextDeltaBlock, ) +from openai.types.beta.threads import ( + Message as ThreadMessage, +) from openai.types.beta.threads.run_create_params import AdditionalMessage from openai.types.beta.threads.run_submit_tool_outputs_params import ToolOutput from openai.types.beta.threads.runs import RunStep @@ -634,7 +636,7 @@ async def _process_stream_events(self, stream: Any, thread_id: str) -> AsyncIter } if annotation.file_citation and annotation.file_citation.quote: props["quote"] = annotation.file_citation.quote - ann: Annotation = Annotation( + ann = Annotation( type="citation", additional_properties=props, raw_representation=annotation, diff --git a/python/packages/core/tests/openai/test_openai_assistants_client.py b/python/packages/core/tests/openai/test_openai_assistants_client.py index 8449eb8ca3..e7968f793d 100644 --- a/python/packages/core/tests/openai/test_openai_assistants_client.py +++ b/python/packages/core/tests/openai/test_openai_assistants_client.py @@ -7,19 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from openai.types.beta.threads import ( - FileCitationAnnotation, - FilePathAnnotation, - Message as ThreadMessage, - MessageDeltaEvent, - Run, - TextDeltaBlock, -) -from openai.types.beta.threads.file_citation_delta_annotation import FileCitationDeltaAnnotation -from openai.types.beta.threads.file_path_delta_annotation import FilePathDeltaAnnotation -from openai.types.beta.threads.runs import RunStep -from pydantic import Field - from agent_framework import ( Agent, AgentResponse, @@ -33,6 +20,20 @@ tool, ) from agent_framework.openai import OpenAIAssistantsClient +from openai.types.beta.threads import ( + FileCitationAnnotation, + FilePathAnnotation, + MessageDeltaEvent, + Run, + TextDeltaBlock, +) +from openai.types.beta.threads import ( + Message as ThreadMessage, +) +from openai.types.beta.threads.file_citation_delta_annotation import FileCitationDeltaAnnotation +from openai.types.beta.threads.file_path_delta_annotation import FilePathDeltaAnnotation +from openai.types.beta.threads.runs import RunStep +from pydantic import Field skip_if_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"), @@ -1690,8 +1691,7 @@ class TestMessageCompletedAnnotations: def client(self): """Create a client instance for testing.""" with patch.object(OpenAIAssistantsClient, "__init__", lambda self, **kw: None): - c = object.__new__(OpenAIAssistantsClient) - return c + return object.__new__(OpenAIAssistantsClient) @pytest.mark.asyncio async def test_message_completed_with_file_citation(self, client): From 48f9e365c5b4976b9267dd2034976225d3ce3821 Mon Sep 17 00:00:00 2001 From: LEDazzio01 <170764058+LEDazzio01@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:49:51 -0500 Subject: [PATCH 8/9] fix: rename annotation variable in completed block to fix mypy type conflict The 'annotation' loop variable in thread.message.completed has type FileCitationAnnotation | FilePathAnnotation, which conflicts with the delta block's 'annotation' of type FileCitationDeltaAnnotation | FilePathDeltaAnnotation. Renamed to 'completed_annotation' to avoid mypy 'Incompatible types in assignment' error. --- .../openai/_assistants_client.py | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 6f565291af..39adb2f45c 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -629,50 +629,50 @@ async def _process_stream_events(self, stream: Any, thread_id: str) -> AsyncIter text_content = Content.from_text(block.text.value) if block.text.annotations: text_content.annotations = [] - for annotation in block.text.annotations: - if isinstance(annotation, FileCitationAnnotation): + for completed_annotation in block.text.annotations: + if isinstance(completed_annotation, FileCitationAnnotation): props: dict[str, Any] = { - "text": annotation.text, + "text": completed_annotation.text, } - if annotation.file_citation and annotation.file_citation.quote: - props["quote"] = annotation.file_citation.quote + if completed_annotation.file_citation and completed_annotation.file_citation.quote: + props["quote"] = completed_annotation.file_citation.quote ann = Annotation( type="citation", additional_properties=props, - raw_representation=annotation, + raw_representation=completed_annotation, ) - if annotation.file_citation and annotation.file_citation.file_id: - ann["file_id"] = annotation.file_citation.file_id - if annotation.start_index is not None and annotation.end_index is not None: + if completed_annotation.file_citation and completed_annotation.file_citation.file_id: + ann["file_id"] = completed_annotation.file_citation.file_id + if completed_annotation.start_index is not None and completed_annotation.end_index is not None: ann["annotated_regions"] = [ TextSpanRegion( type="text_span", - start_index=annotation.start_index, - end_index=annotation.end_index, + start_index=completed_annotation.start_index, + end_index=completed_annotation.end_index, ) ] text_content.annotations.append(ann) - elif isinstance(annotation, FilePathAnnotation): + elif isinstance(completed_annotation, FilePathAnnotation): ann = Annotation( type="citation", additional_properties={ - "text": annotation.text, + "text": completed_annotation.text, }, - raw_representation=annotation, + raw_representation=completed_annotation, ) - if annotation.file_path and annotation.file_path.file_id: - ann["file_id"] = annotation.file_path.file_id - if annotation.start_index is not None and annotation.end_index is not None: + if completed_annotation.file_path and completed_annotation.file_path.file_id: + ann["file_id"] = completed_annotation.file_path.file_id + if completed_annotation.start_index is not None and completed_annotation.end_index is not None: ann["annotated_regions"] = [ TextSpanRegion( type="text_span", - start_index=annotation.start_index, - end_index=annotation.end_index, + start_index=completed_annotation.start_index, + end_index=completed_annotation.end_index, ) ] text_content.annotations.append(ann) else: - logger.debug("Unparsed annotation type: %s", annotation.type) + logger.debug("Unparsed annotation type: %s", completed_annotation.type) completed_contents.append(text_content) if completed_contents: yield ChatResponseUpdate( From 8e53849126fe29ea9badd1426674f1147aca6bca Mon Sep 17 00:00:00 2001 From: LEDazzio01 <170764058+LEDazzio01@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:17:45 -0500 Subject: [PATCH 9/9] fix: remove quote field from FileCitationAnnotation handling --- .../openai/_assistants_client.py | 2 -- .../openai/test_openai_assistants_client.py | 36 ------------------- 2 files changed, 38 deletions(-) diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 39adb2f45c..1c8aafc94e 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -634,8 +634,6 @@ async def _process_stream_events(self, stream: Any, thread_id: str) -> AsyncIter props: dict[str, Any] = { "text": completed_annotation.text, } - if completed_annotation.file_citation and completed_annotation.file_citation.quote: - props["quote"] = completed_annotation.file_citation.quote ann = Annotation( type="citation", additional_properties=props, diff --git a/python/packages/core/tests/openai/test_openai_assistants_client.py b/python/packages/core/tests/openai/test_openai_assistants_client.py index e7968f793d..1ce40eeba0 100644 --- a/python/packages/core/tests/openai/test_openai_assistants_client.py +++ b/python/packages/core/tests/openai/test_openai_assistants_client.py @@ -1610,7 +1610,6 @@ def _make_file_citation_annotation( file_id: str = "file-abc123", start_index: int = 10, end_index: int = 24, - quote: str | None = None, ) -> MagicMock: """Create a mock FileCitationAnnotation.""" annotation = MagicMock(spec=FileCitationAnnotation) @@ -1619,7 +1618,6 @@ def _make_file_citation_annotation( annotation.end_index = end_index annotation.file_citation = MagicMock() annotation.file_citation.file_id = file_id - annotation.file_citation.quote = quote return annotation @@ -1722,41 +1720,7 @@ async def test_message_completed_with_file_citation(self, client): assert ann["annotated_regions"][0]["start_index"] == 10 assert ann["annotated_regions"][0]["end_index"] == 24 - @pytest.mark.asyncio - async def test_message_completed_with_file_citation_quote(self, client): - """Verify the quote field from file_citation is included in additional_properties.""" - citation = _make_file_citation_annotation( - text="【4:0†source】", - file_id="file-abc123", - start_index=10, - end_index=24, - quote="The exact quoted text from the source document.", - ) - text_block = _make_text_block("Some text【4:0†source】", [citation]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - ann = updates[0].contents[0].annotations[0] - assert ann["additional_properties"]["quote"] == "The exact quoted text from the source document." - @pytest.mark.asyncio - async def test_message_completed_with_file_citation_no_quote(self, client): - """Verify annotations work when quote is None (not all citations have quotes).""" - citation = _make_file_citation_annotation( - text="【4:0†source】", file_id="file-abc123", start_index=10, end_index=24, quote=None - ) - text_block = _make_text_block("Some text【4:0†source】", [citation]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - ann = updates[0].contents[0].annotations[0] - assert "quote" not in ann["additional_properties"] @pytest.mark.asyncio async def test_message_completed_with_file_path(self, client):