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"