From 25c66187dbb228bb1b7ea55a93352bdfe9d26544 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/3] Python: Support ChatKit structured input conversion --- .../agent_framework_chatkit/_converter.py | 22 ++++++- .../packages/chatkit/tests/test_converter.py | 58 +++++++++++++++++++ 2 files changed, 78 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..bc9c546552a 100644 --- a/python/packages/chatkit/agent_framework_chatkit/_converter.py +++ b/python/packages/chatkit/agent_framework_chatkit/_converter.py @@ -491,6 +491,25 @@ 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 _structured_input_to_input(self, item: StructuredInputItem) -> Message: + lines: list[str] = [] + for structured_input in item.inputs: + answer = structured_input.answer + if answer is None: + answer_text = "unanswered" + elif answer.skipped: + answer_text = "skipped" + else: + answer_text = ", ".join(answer.values) + + lines.append(f"- {structured_input.question}: {answer_text}") + + text = ( + "A structured input request was displayed to the user with the following " + f"status: {item.status}\n\n" + "\n".join(lines) + "\n" + ) + return Message(role="user", contents=[text]) + async def _thread_item_to_input_item( self, item: ThreadItem, @@ -537,8 +556,7 @@ async def _thread_item_to_input_item( # TODO(evmattso): Implement generated image handling in a future PR return [] case StructuredInputItem(): - # TODO(evmattso): Implement structured input handling in a future PR - return [] + return [self._structured_input_to_input(item)] case _: # Unknown ThreadItem variant (e.g. types added in newer chatkit versions). # Skip rather than fail so we remain forward-compatible with chatkit upgrades. diff --git a/python/packages/chatkit/tests/test_converter.py b/python/packages/chatkit/tests/test_converter.py index a0fb0244549..d6c4c2e58cd 100644 --- a/python/packages/chatkit/tests/test_converter.py +++ b/python/packages/chatkit/tests/test_converter.py @@ -641,6 +641,58 @@ 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_structured_input(self, converter): + """Test structured input answers are preserved as model context.""" + from chatkit.types import ( + StructuredInputAnswer, + StructuredInputFreeform, + StructuredInputItem, + StructuredInputMultipleChoice, + StructuredInputMultipleChoiceOption, + ) + + input_item = StructuredInputItem( + id="structured_1", + thread_id="thread_1", + created_at=datetime.now(), + type="structured_input", + status="answered", + inputs=[ + StructuredInputMultipleChoice( + id="priority", + question="What priority should I use?", + options=[ + StructuredInputMultipleChoiceOption(value="High"), + StructuredInputMultipleChoiceOption(value="Urgent"), + ], + multiple=True, + answer=StructuredInputAnswer(values=["High", "Urgent"]), + ), + StructuredInputFreeform( + id="notes", + question="Any extra context?", + answer=StructuredInputAnswer(skipped=True), + ), + StructuredInputFreeform( + id="owner", + question="Who owns this?", + ), + ], + ) + + result = await converter.to_agent_input(input_item) + + assert len(result) == 1 + assert result[0].role == "user" + assert result[0].text == ( + "A structured input request was displayed to the user with the following status: answered\n" + "\n" + "- What priority should I use?: High, Urgent\n" + "- Any extra context?: skipped\n" + "- Who owns this?: unanswered\n" + "" + ) + 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 ( @@ -743,6 +795,7 @@ async def test_to_agent_input_dispatches_supported_variants(self, converter): "user", "system", "system", + "user", ] assert result[0].text == "Assistant" assert result[2].contents[0].result is not None @@ -751,6 +804,11 @@ async def test_to_agent_input_dispatches_supported_variants(self, converter): assert "Analysis: Completed" in result[5].text assert result[6].text == "secret" assert result[7].text == "sdk secret" + assert result[8].text == ( + "A structured input request was displayed to the user with the following status: pending\n" + "\n" + "\n" + ) class TestSimpleToAgentInput: From 73b62c1f50608e194a41c4f60f522aca874189d4 Mon Sep 17 00:00:00 2001 From: quifox <289420841+quifox@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:24:23 +0800 Subject: [PATCH 2/3] Python: Expose async ChatKit structured input conversion hook --- python/packages/chatkit/AGENTS.md | 3 + python/packages/chatkit/README.md | 4 ++ .../agent_framework_chatkit/_converter.py | 19 +++++- .../packages/chatkit/tests/test_converter.py | 59 ++++++++++++++++++- 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/python/packages/chatkit/AGENTS.md b/python/packages/chatkit/AGENTS.md index 7855b4d4caf..d11724b3ad1 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.structured_input_to_input()` is an async override hook returning +`Message | list[Message] | None`; `to_agent_input()` normalizes its output and preserves thread order. + ## Usage ```python diff --git a/python/packages/chatkit/README.md b/python/packages/chatkit/README.md index c4cdb364316..ac154fbec29 100644 --- a/python/packages/chatkit/README.md +++ b/python/packages/chatkit/README.md @@ -12,6 +12,10 @@ 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.structured_input_to_input()` method to +customize structured input context, such as formatting or redacting answers. +Return a `Message`, a list of messages, or `None` to skip the item. + ## Installation ```bash diff --git a/python/packages/chatkit/agent_framework_chatkit/_converter.py b/python/packages/chatkit/agent_framework_chatkit/_converter.py index bc9c546552a..f6501178f9e 100644 --- a/python/packages/chatkit/agent_framework_chatkit/_converter.py +++ b/python/packages/chatkit/agent_framework_chatkit/_converter.py @@ -491,7 +491,21 @@ 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 _structured_input_to_input(self, item: StructuredInputItem) -> Message: + async def structured_input_to_input(self, item: StructuredInputItem) -> Message | list[Message] | None: + """Convert structured-input status and answers to Agent Framework Message(s). + + This method is called internally by `to_agent_input()`. Override this method + to customize formatting or redact answers, or return None to skip the item. + + Args: + item: The ChatKit structured input item to convert. + + Returns: + A Message with user role, a list of messages, or None to skip. + + Note: + Use `to_agent_input()` to convert thread items with proper message ordering. + """ lines: list[str] = [] for structured_input in item.inputs: answer = structured_input.answer @@ -556,7 +570,8 @@ async def _thread_item_to_input_item( # TODO(evmattso): Implement generated image handling in a future PR return [] case StructuredInputItem(): - return [self._structured_input_to_input(item)] + out = await self.structured_input_to_input(item) or [] + return out if isinstance(out, list) else [out] case _: # Unknown ThreadItem variant (e.g. types added in newer chatkit versions). # Skip rather than fail so we remain forward-compatible with chatkit upgrades. diff --git a/python/packages/chatkit/tests/test_converter.py b/python/packages/chatkit/tests/test_converter.py index d6c4c2e58cd..fb08f0e7ba8 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_structured_input(self, converter): + @pytest.mark.parametrize("status", ["pending", "answered", "skipped"]) + async def test_to_agent_input_with_structured_input(self, converter, status): """Test structured input answers are preserved as model context.""" from chatkit.types import ( StructuredInputAnswer, @@ -656,7 +658,7 @@ async def test_to_agent_input_with_structured_input(self, converter): thread_id="thread_1", created_at=datetime.now(), type="structured_input", - status="answered", + status=status, inputs=[ StructuredInputMultipleChoice( id="priority", @@ -685,7 +687,7 @@ async def test_to_agent_input_with_structured_input(self, converter): assert len(result) == 1 assert result[0].role == "user" assert result[0].text == ( - "A structured input request was displayed to the user with the following status: answered\n" + f"A structured input request was displayed to the user with the following status: {status}\n" "\n" "- What priority should I use?: High, Urgent\n" "- Any extra context?: skipped\n" @@ -693,6 +695,57 @@ async def test_to_agent_input_with_structured_input(self, converter): "" ) + @pytest.mark.parametrize( + "converted", + [ + None, + [], + Message(role="user", contents=["redacted"]), + [Message("user", ["first"]), Message("user", ["second"])], + ], + ids=["skip", "empty", "single", "multiple"], + ) + async def test_to_agent_input_uses_structured_input_override( + self, converted: Message | list[Message] | None + ) -> None: + """Test async overrides can skip or expand an item without changing message order.""" + from chatkit.types import HiddenContextItem, StructuredInputItem + + calls: list[StructuredInputItem] = [] + + class CustomConverter(ThreadItemConverter): + async def structured_input_to_input(self, item: StructuredInputItem) -> Message | list[Message] | None: + calls.append(item) + return converted + + input_item = StructuredInputItem(id="structured_1", thread_id="thread_1", created_at=datetime.now(), inputs=[]) + 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]) + + expected = converted if isinstance(converted, list) else [converted] if converted is not None else [] + assert calls == [input_item] + assert result[1:-1] == expected + 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_structured_input_override_errors(self, error: BaseException) -> None: + """Test custom conversion failures and cancellation propagate to the caller.""" + from chatkit.types import StructuredInputItem + + class FailingConverter(ThreadItemConverter): + async def structured_input_to_input(self, item: StructuredInputItem) -> Message | list[Message] | None: + raise error + + input_item = StructuredInputItem(id="structured_1", thread_id="thread_1", created_at=datetime.now(), inputs=[]) + + 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 ( From 43eeee3d42082c6629fd79134fb23d4556be3e1b Mon Sep 17 00:00:00 2001 From: quifox <289420841+quifox@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:30:06 +0800 Subject: [PATCH 3/3] Python: Specify ChatKit override test expectations independently --- python/packages/chatkit/tests/test_converter.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/python/packages/chatkit/tests/test_converter.py b/python/packages/chatkit/tests/test_converter.py index fb08f0e7ba8..b9889f14db2 100644 --- a/python/packages/chatkit/tests/test_converter.py +++ b/python/packages/chatkit/tests/test_converter.py @@ -696,17 +696,17 @@ async def test_to_agent_input_with_structured_input(self, converter, status): ) @pytest.mark.parametrize( - "converted", + ("converted", "expected_texts"), [ - None, - [], - Message(role="user", contents=["redacted"]), - [Message("user", ["first"]), Message("user", ["second"])], + (None, []), + ([], []), + (Message(role="user", contents=["redacted"]), ["redacted"]), + ([Message("user", ["first"]), Message("user", ["second"])], ["first", "second"]), ], ids=["skip", "empty", "single", "multiple"], ) async def test_to_agent_input_uses_structured_input_override( - self, converted: Message | list[Message] | None + self, converted: Message | list[Message] | None, expected_texts: list[str] ) -> None: """Test async overrides can skip or expand an item without changing message order.""" from chatkit.types import HiddenContextItem, StructuredInputItem @@ -724,9 +724,8 @@ async def structured_input_to_input(self, item: StructuredInputItem) -> Message result = await CustomConverter().to_agent_input([before, input_item, after]) - expected = converted if isinstance(converted, list) else [converted] if converted is not None else [] assert calls == [input_item] - assert result[1:-1] == expected + assert [message.text for message in result[1:-1]] == expected_texts assert result[0].text == "before" assert result[-1].text == "after"