From bb6b61d8272a84d884e16038d5a1b848fa589b07 Mon Sep 17 00:00:00 2001 From: quifox <289420841+quifox@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:51:28 +0800 Subject: [PATCH 1/2] Python: Support ChatKit generated image conversion --- .../agent_framework_chatkit/_converter.py | 16 ++++++++++++-- .../packages/chatkit/tests/test_converter.py | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/python/packages/chatkit/agent_framework_chatkit/_converter.py b/python/packages/chatkit/agent_framework_chatkit/_converter.py index 88f75598b5b..8dc7b673277 100644 --- a/python/packages/chatkit/agent_framework_chatkit/_converter.py +++ b/python/packages/chatkit/agent_framework_chatkit/_converter.py @@ -491,6 +491,18 @@ async def end_of_turn_to_input(self, item: EndOfTurnItem) -> Message | list[Mess # End-of-turn is only used for UI hints - skip it return None + def _generated_image_to_input(self, item: GeneratedImageItem) -> Message | None: + if item.image is None: + return None + + return Message( + role="user", + contents=[ + Content.from_text(text="The following image was generated by the agent."), + Content.from_uri(uri=item.image.url, media_type="image/*"), + ], + ) + async def _thread_item_to_input_item( self, item: ThreadItem, @@ -534,8 +546,8 @@ async def _thread_item_to_input_item( out = self.hidden_context_to_input(item) or [] return out if isinstance(out, list) else [out] case GeneratedImageItem(): - # TODO(evmattso): Implement generated image handling in a future PR - return [] + out = self._generated_image_to_input(item) + return [] if out is None else [out] case StructuredInputItem(): # TODO(evmattso): Implement structured input handling in a future PR return [] diff --git a/python/packages/chatkit/tests/test_converter.py b/python/packages/chatkit/tests/test_converter.py index a0fb0244549..920397989c5 100644 --- a/python/packages/chatkit/tests/test_converter.py +++ b/python/packages/chatkit/tests/test_converter.py @@ -641,6 +641,27 @@ async def test_end_of_turn_to_input_returns_none(self, converter): assert await converter.end_of_turn_to_input(end_item) is None + async def test_to_agent_input_with_generated_image(self, converter): + """Test generated images are preserved as multimodal model context.""" + from chatkit.types import GeneratedImage, GeneratedImageItem + + input_item = GeneratedImageItem( + id="generated_1", + thread_id="thread_1", + created_at=datetime.now(), + type="generated_image", + image=GeneratedImage(id="image_1", url="https://example.com/image.png"), + ) + + result = await converter.to_agent_input(input_item) + + assert len(result) == 1 + assert result[0].role == "user" + assert [content.type for content in result[0].contents] == ["text", "uri"] + assert result[0].contents[0].text == "The following image was generated by the agent." + assert result[0].contents[1].uri == "https://example.com/image.png" + assert result[0].contents[1].media_type == "image/*" + async def test_to_agent_input_dispatches_supported_variants(self, converter): """Test thread item dispatch converts supported items and skips unsupported variants.""" from chatkit.types import ( From fbafa8fa97ffe5e60a96597816587c3738acebcc Mon Sep 17 00:00:00 2001 From: quifox <289420841+quifox@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:43:04 +0800 Subject: [PATCH 2/2] Python: Preserve generated image MIME types and expose conversion hook --- python/packages/chatkit/AGENTS.md | 3 + python/packages/chatkit/README.md | 5 + .../agent_framework_chatkit/_converter.py | 26 ++++- .../packages/chatkit/tests/test_converter.py | 98 ++++++++++++++++++- 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/python/packages/chatkit/AGENTS.md b/python/packages/chatkit/AGENTS.md index 7855b4d4caf..01158615f00 100644 --- a/python/packages/chatkit/AGENTS.md +++ b/python/packages/chatkit/AGENTS.md @@ -8,6 +8,9 @@ Integration with OpenAI ChatKit (Python) for building chat UIs. - **`stream_agent_response()`** - Stream agent responses to ChatKit - **`simple_to_agent_input()`** - Convert simple input to agent input format +`ThreadItemConverter.generated_image_to_input()` is an async override hook returning +`Message | list[Message] | None`; data URI media types are inferred by `Content.from_uri()`. + ## Usage ```python diff --git a/python/packages/chatkit/README.md b/python/packages/chatkit/README.md index c4cdb364316..42c26f5b0e7 100644 --- a/python/packages/chatkit/README.md +++ b/python/packages/chatkit/README.md @@ -12,6 +12,11 @@ Specifically, it mirrors the [Agent SDK integration](https://github.com/openai/c of `ThreadItemConverter` to convert a ChatKit thread to a list of `Message`, useful for getting started quickly. +Override the async `ThreadItemConverter.generated_image_to_input()` method to +customize generated image context, such as resolving private image URLs. +Return a `Message`, a list of messages, or `None` to skip the item. +The default converter preserves data URI media types and leaves external URLs unchanged. + ## Installation ```bash diff --git a/python/packages/chatkit/agent_framework_chatkit/_converter.py b/python/packages/chatkit/agent_framework_chatkit/_converter.py index 8dc7b673277..11d32abd247 100644 --- a/python/packages/chatkit/agent_framework_chatkit/_converter.py +++ b/python/packages/chatkit/agent_framework_chatkit/_converter.py @@ -491,15 +491,33 @@ async def end_of_turn_to_input(self, item: EndOfTurnItem) -> Message | list[Mess # End-of-turn is only used for UI hints - skip it return None - def _generated_image_to_input(self, item: GeneratedImageItem) -> Message | None: + async def generated_image_to_input(self, item: GeneratedImageItem) -> Message | list[Message] | None: + """Convert a generated image to Agent Framework Message(s). + + This method is called internally by `to_agent_input()`. Override this method + to customize image conversion, such as resolving a private image URL. + + Args: + item: The ChatKit generated image item to convert. + + Returns: + A Message with user role, a list of messages, or None to skip. + + Raises: + ContentError: If the generated image URL is invalid. + + Note: + Use `to_agent_input()` to convert thread items with proper message ordering. + """ if item.image is None: return None + uri = item.image.url return Message( role="user", contents=[ Content.from_text(text="The following image was generated by the agent."), - Content.from_uri(uri=item.image.url, media_type="image/*"), + Content.from_uri(uri=uri, media_type=None if uri.startswith("data:") else "image/*"), ], ) @@ -546,8 +564,8 @@ async def _thread_item_to_input_item( out = self.hidden_context_to_input(item) or [] return out if isinstance(out, list) else [out] case GeneratedImageItem(): - out = self._generated_image_to_input(item) - return [] if out is None else [out] + out = await self.generated_image_to_input(item) or [] + return out if isinstance(out, list) else [out] case StructuredInputItem(): # TODO(evmattso): Implement structured input handling in a future PR return [] diff --git a/python/packages/chatkit/tests/test_converter.py b/python/packages/chatkit/tests/test_converter.py index 920397989c5..7ed897cf075 100644 --- a/python/packages/chatkit/tests/test_converter.py +++ b/python/packages/chatkit/tests/test_converter.py @@ -2,6 +2,7 @@ """Tests for ChatKit to Agent Framework converter utilities.""" +import asyncio from datetime import datetime from types import SimpleNamespace from unittest.mock import Mock @@ -641,7 +642,8 @@ async def test_end_of_turn_to_input_returns_none(self, converter): assert await converter.end_of_turn_to_input(end_item) is None - async def test_to_agent_input_with_generated_image(self, converter): + @pytest.mark.parametrize("url", ["https://example.com/image.png", "https://example.com/asset?signature=sample"]) + async def test_to_agent_input_with_generated_image(self, converter, url): """Test generated images are preserved as multimodal model context.""" from chatkit.types import GeneratedImage, GeneratedImageItem @@ -650,7 +652,7 @@ async def test_to_agent_input_with_generated_image(self, converter): thread_id="thread_1", created_at=datetime.now(), type="generated_image", - image=GeneratedImage(id="image_1", url="https://example.com/image.png"), + image=GeneratedImage(id="image_1", url=url), ) result = await converter.to_agent_input(input_item) @@ -659,9 +661,99 @@ async def test_to_agent_input_with_generated_image(self, converter): assert result[0].role == "user" assert [content.type for content in result[0].contents] == ["text", "uri"] assert result[0].contents[0].text == "The following image was generated by the agent." - assert result[0].contents[1].uri == "https://example.com/image.png" + assert result[0].contents[1].uri == url assert result[0].contents[1].media_type == "image/*" + @pytest.mark.parametrize("media_type", ["image/png", "image/jpeg", "image/webp"]) + async def test_to_agent_input_preserves_generated_image_data_uri_media_type(self, converter, media_type): + """Test data URI media types remain available to provider serializers.""" + from chatkit.types import GeneratedImage, GeneratedImageItem + + uri = f"data:{media_type};base64,YQ==" + input_item = GeneratedImageItem( + id="generated_1", + thread_id="thread_1", + created_at=datetime.now(), + image=GeneratedImage(id="image_1", url=uri), + ) + + result = await converter.to_agent_input(input_item) + + assert len(result) == 1 + assert result[0].role == "user" + assert [content.type for content in result[0].contents] == ["text", "data"] + assert result[0].contents[1].uri == uri + assert result[0].contents[1].media_type == media_type + + async def test_generated_image_to_input_without_image_returns_none(self, converter): + """Test unfinished image items are skipped by both conversion entry points.""" + from chatkit.types import GeneratedImageItem + + input_item = GeneratedImageItem(id="generated_1", thread_id="thread_1", created_at=datetime.now()) + + assert await converter.generated_image_to_input(input_item) is None + assert await converter.to_agent_input(input_item) == [] + + @pytest.mark.parametrize( + ("converted", "expected_texts"), + [ + (None, []), + ([], []), + (Message("user", ["image omitted"]), ["image omitted"]), + ([Message("user", ["first"]), Message("user", ["second"])], ["first", "second"]), + ], + ids=["skip", "empty", "single", "multiple"], + ) + async def test_to_agent_input_uses_generated_image_override( + self, converted: Message | list[Message] | None, expected_texts: list[str] + ) -> None: + """Test async image overrides can skip or expand an item while preserving order.""" + from chatkit.types import GeneratedImage, GeneratedImageItem, HiddenContextItem + + calls: list[GeneratedImageItem] = [] + + class CustomConverter(ThreadItemConverter): + async def generated_image_to_input(self, item: GeneratedImageItem) -> Message | list[Message] | None: + calls.append(item) + return converted + + input_item = GeneratedImageItem( + id="generated_1", + thread_id="thread_1", + created_at=datetime.now(), + image=GeneratedImage(id="image_1", url="https://example.com/image.png"), + ) + before = HiddenContextItem(id="before", thread_id="thread_1", created_at=datetime.now(), content="before") + after = HiddenContextItem(id="after", thread_id="thread_1", created_at=datetime.now(), content="after") + + result = await CustomConverter().to_agent_input([before, input_item, after]) + + assert calls == [input_item] + assert [message.text for message in result[1:-1]] == expected_texts + assert result[0].text == "before" + assert result[-1].text == "after" + + @pytest.mark.parametrize("error", [ValueError("conversion failed"), asyncio.CancelledError()]) + async def test_to_agent_input_propagates_generated_image_override_errors(self, error: BaseException) -> None: + """Test custom image conversion failures and cancellation propagate to the caller.""" + from chatkit.types import GeneratedImage, GeneratedImageItem + + class FailingConverter(ThreadItemConverter): + async def generated_image_to_input(self, item: GeneratedImageItem) -> Message | list[Message] | None: + raise error + + input_item = GeneratedImageItem( + id="generated_1", + thread_id="thread_1", + created_at=datetime.now(), + image=GeneratedImage(id="image_1", url="https://example.com/image.png"), + ) + + with pytest.raises(type(error)) as exception: + await FailingConverter().to_agent_input(input_item) + + assert exception.value is error + async def test_to_agent_input_dispatches_supported_variants(self, converter): """Test thread item dispatch converts supported items and skips unsupported variants.""" from chatkit.types import (