Python: fix(python): Handle thread.message.completed event in Assistants API streaming (#4333)

* 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

* 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

* 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

* 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

* fix: address reviewer nits — logger name convention + annotation type string

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__

* 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.

* 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)

* 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.

* fix: remove quote field from FileCitationAnnotation handling

---------

Co-authored-by: Giles Odigwe <79032838+giles17@users.noreply.github.com>
This commit is contained in:
L. Elaine Dazzio
2026-03-02 23:07:00 -05:00
committed by GitHub
Unverified
parent ef8e18fb85
commit 869e51fdce
2 changed files with 344 additions and 7 deletions
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import logging
import sys
from collections.abc import (
AsyncIterable,
@@ -16,7 +17,9 @@ from typing import TYPE_CHECKING, Any, Generic, Literal, TypedDict, cast
from openai import AsyncOpenAI
from openai.types.beta.threads import (
FileCitationAnnotation,
FileCitationDeltaAnnotation,
FilePathAnnotation,
FilePathDeltaAnnotation,
ImageURLContentBlockParam,
ImageURLParam,
@@ -26,6 +29,9 @@ from openai.types.beta.threads import (
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
@@ -72,6 +78,8 @@ else:
if TYPE_CHECKING:
from .._middleware import MiddlewareTypes
logger = logging.getLogger("agent_framework.openai")
# region OpenAI Assistants Options TypedDict
@@ -610,6 +618,69 @@ class OpenAIAssistantsClient( # type: ignore[misc]
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 completed_annotation in block.text.annotations:
if isinstance(completed_annotation, FileCitationAnnotation):
props: dict[str, Any] = {
"text": completed_annotation.text,
}
ann = Annotation(
type="citation",
additional_properties=props,
raw_representation=completed_annotation,
)
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=completed_annotation.start_index,
end_index=completed_annotation.end_index,
)
]
text_content.annotations.append(ann)
elif isinstance(completed_annotation, FilePathAnnotation):
ann = Annotation(
type="citation",
additional_properties={
"text": completed_annotation.text,
},
raw_representation=completed_annotation,
)
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=completed_annotation.start_index,
end_index=completed_annotation.end_index,
)
]
text_content.annotations.append(ann)
else:
logger.debug("Unparsed annotation type: %s", completed_annotation.type)
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:
@@ -1,17 +1,12 @@
# 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.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,
@@ -25,6 +20,20 @@ from agent_framework import (
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"),
@@ -1566,3 +1575,260 @@ def test_with_callable_api_key() -> None:
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,
) -> 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
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):
return object.__new__(OpenAIAssistantsClient)
@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"
@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)