diff --git a/python/packages/chatkit/agent_framework_chatkit/_converter.py b/python/packages/chatkit/agent_framework_chatkit/_converter.py
index c4fe24670f2..88f75598b5b 100644
--- a/python/packages/chatkit/agent_framework_chatkit/_converter.py
+++ b/python/packages/chatkit/agent_framework_chatkit/_converter.py
@@ -81,33 +81,40 @@ async def user_message_to_input(
"""
# Extract text content from the user message
text_content = ""
+ contents: list[Content] = []
+
+ def append_text_content() -> None:
+ nonlocal text_content
+ if stripped_text := text_content.strip():
+ contents.append(Content.from_text(text=stripped_text))
+ text_content = ""
+
if item.content:
for content_part in item.content:
if isinstance(content_part, UserMessageTextContent):
text_content += content_part.text
+ elif isinstance(content_part, UserMessageTagContent):
+ tag_content = self.tag_to_message_content(content_part)
+ if tag_content.type == "text":
+ text_content += tag_content.text or ""
+ else:
+ append_text_content()
+ contents.append(tag_content)
+
+ append_text_content()
- # Convert attachments to Content
- data_contents: list[Content] = []
+ # Append attachments after the ordered message content.
if item.attachments:
for attachment in item.attachments:
content = await self.attachment_to_message_content(attachment)
if content is not None:
- data_contents.append(content)
+ contents.append(content)
# Create the message with text and attachments
- if not text_content.strip() and not data_contents:
+ if not contents:
return None
- # If only text and no attachments, use text parameter for simplicity
- if text_content.strip() and not data_contents:
- user_message = Message(role="user", contents=[text_content.strip()])
- else:
- # Build contents list with both text and attachments
- contents: list[Content] = []
- if text_content.strip():
- contents.append(Content.from_text(text=text_content.strip()))
- contents.extend(data_contents)
- user_message = Message(role="user", contents=contents)
+ user_message = Message(role="user", contents=contents)
# Handle quoted text if this is the last message
messages = [user_message]
diff --git a/python/packages/chatkit/tests/test_converter.py b/python/packages/chatkit/tests/test_converter.py
index 730fd6ec6e1..a0fb0244549 100644
--- a/python/packages/chatkit/tests/test_converter.py
+++ b/python/packages/chatkit/tests/test_converter.py
@@ -7,8 +7,8 @@
from unittest.mock import Mock
import pytest
-from agent_framework import Message
-from chatkit.types import InferenceOptions, UserMessageTextContent
+from agent_framework import Content, Message
+from chatkit.types import InferenceOptions, UserMessageTagContent, UserMessageTextContent
from pydantic import AnyUrl
from agent_framework_chatkit import ThreadItemConverter, simple_to_agent_input
@@ -110,6 +110,98 @@ async def test_to_agent_input_multiple_content_parts(self, converter):
assert len(result) == 1
assert result[0].text == "Hello world!"
+ async def test_to_agent_input_keeps_tag_inline_with_text(self, converter):
+ """Test converting user message tags in their original text position."""
+ from chatkit.types import UserMessageItem
+
+ input_item = UserMessageItem(
+ id="msg_tag",
+ thread_id="thread_1",
+ created_at=datetime.now(),
+ type="user_message",
+ content=[
+ UserMessageTextContent(text="Ask "),
+ UserMessageTagContent(
+ type="input_tag",
+ id="tag_1",
+ text="john",
+ data={"name": "John Doe"},
+ interactive=False,
+ ),
+ UserMessageTextContent(text=" about the report."),
+ ],
+ attachments=[],
+ inference_options=InferenceOptions(),
+ )
+
+ result = await converter.to_agent_input(input_item)
+
+ assert len(result) == 1
+ assert result[0].text == "Ask Name:John Doe about the report."
+
+ async def test_to_agent_input_keeps_tag_only_message(self, converter):
+ """Test that a user message containing only a tag is not discarded."""
+ from chatkit.types import UserMessageItem
+
+ input_item = UserMessageItem(
+ id="msg_tag_only",
+ thread_id="thread_1",
+ created_at=datetime.now(),
+ type="user_message",
+ content=[
+ UserMessageTagContent(
+ type="input_tag",
+ id="tag_1",
+ text="john",
+ data={"name": "John Doe"},
+ interactive=False,
+ )
+ ],
+ attachments=[],
+ inference_options=InferenceOptions(),
+ )
+
+ result = await converter.to_agent_input(input_item)
+
+ assert len(result) == 1
+ assert result[0].text == "Name:John Doe"
+
+ async def test_to_agent_input_preserves_non_text_tag_position(self):
+ """Test that custom non-text tag conversions remain between adjacent text."""
+ from chatkit.types import UserMessageItem
+
+ class UriTagConverter(ThreadItemConverter):
+ def tag_to_message_content(self, tag: UserMessageTagContent) -> Content:
+ return Content.from_uri(uri=f"https://example.com/users/{tag.text}", media_type="text/html")
+
+ input_item = UserMessageItem(
+ id="msg_uri_tag",
+ thread_id="thread_1",
+ created_at=datetime.now(),
+ type="user_message",
+ content=[
+ UserMessageTextContent(text="Ask"),
+ UserMessageTagContent(
+ type="input_tag",
+ id="tag_1",
+ text="john",
+ data={"name": "John Doe"},
+ interactive=False,
+ ),
+ UserMessageTextContent(text="about the report."),
+ ],
+ attachments=[],
+ inference_options=InferenceOptions(),
+ )
+
+ result = await UriTagConverter().to_agent_input(input_item)
+
+ assert len(result) == 1
+ assert [content.type for content in result[0].contents] == ["text", "uri", "text"]
+ assert result[0].contents[0].text == "Ask"
+ assert result[0].contents[1].uri == "https://example.com/users/john"
+ assert result[0].contents[2].text == "about the report."
+
async def test_to_agent_input_with_quoted_text_for_last_message(self, converter):
"""Test quoted text is prepended as context for the last user message."""
from chatkit.types import UserMessageItem