Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions python/packages/chatkit/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions python/packages/chatkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 35 additions & 2 deletions python/packages/chatkit/agent_framework_chatkit/_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,39 @@ 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

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
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<StructuredInput>\n" + "\n".join(lines) + "\n</StructuredInput>"
)
return Message(role="user", contents=[text])

async def _thread_item_to_input_item(
self,
item: ThreadItem,
Expand Down Expand Up @@ -537,8 +570,8 @@ 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 []
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.
Expand Down
110 changes: 110 additions & 0 deletions python/packages/chatkit/tests/test_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -641,6 +642,109 @@ async def test_end_of_turn_to_input_returns_none(self, converter):

assert await converter.end_of_turn_to_input(end_item) is None

@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,
StructuredInputFreeform,
StructuredInputItem,
StructuredInputMultipleChoice,
StructuredInputMultipleChoiceOption,
)

input_item = StructuredInputItem(
id="structured_1",
thread_id="thread_1",
created_at=datetime.now(),
type="structured_input",
status=status,
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 == (
f"A structured input request was displayed to the user with the following status: {status}\n"
"<StructuredInput>\n"
"- What priority should I use?: High, Urgent\n"
"- Any extra context?: skipped\n"
"- Who owns this?: unanswered\n"
"</StructuredInput>"
)

@pytest.mark.parametrize(
("converted", "expected_texts"),
[
(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, expected_texts: list[str]
) -> 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])

assert calls == [input_item]
assert [message.text for message in result[1:-1]] == expected_texts
assert result[0].text == "<HIDDEN_CONTEXT>before</HIDDEN_CONTEXT>"
assert result[-1].text == "<HIDDEN_CONTEXT>after</HIDDEN_CONTEXT>"

@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 (
Expand Down Expand Up @@ -743,6 +847,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
Expand All @@ -751,6 +856,11 @@ async def test_to_agent_input_dispatches_supported_variants(self, converter):
assert "Analysis: Completed" in result[5].text
assert result[6].text == "<HIDDEN_CONTEXT>secret</HIDDEN_CONTEXT>"
assert result[7].text == "<HIDDEN_CONTEXT>sdk secret</HIDDEN_CONTEXT>"
assert result[8].text == (
"A structured input request was displayed to the user with the following status: pending\n"
"<StructuredInput>\n"
"\n</StructuredInput>"
)


class TestSimpleToAgentInput:
Expand Down
Loading