Compare commits

...

7 Commits

Author SHA1 Message Date
Paulus Schoutsen
8a88b082a1 Fix tests
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 14:41:40 -05:00
Paulus Schoutsen
3a36c060f6 Fix tests 2025-11-02 14:05:45 -05:00
Michael Hansen
55bec3ea61 Merge branch 'dev' into chat-log-subscription 2025-10-27 23:06:15 -05:00
Michael Hansen
2f453a09ff Merge branch 'dev' into chat-log-subscription 2025-10-27 16:12:36 -05:00
Paulus Schoutsen
15f42a205a Add not found error 2025-10-27 11:07:27 -07:00
Paulus Schoutsen
45c48f25d9 More timestamps 2025-10-26 14:06:40 -04:00
Paulus Schoutsen
646f685a8f Add websocket command to interact with chat logs 2025-10-26 11:31:01 -04:00
15 changed files with 869 additions and 9 deletions

View File

@@ -7,6 +7,7 @@ from collections.abc import AsyncGenerator, AsyncIterable, Callable, Generator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime
import logging
from pathlib import Path
from typing import Any, Literal, TypedDict, cast
@@ -16,14 +17,18 @@ import voluptuous as vol
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import chat_session, frame, intent, llm, template
from homeassistant.util.dt import utcnow
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import JsonObjectType
from . import trace
from .const import ChatLogEventType
from .models import ConversationInput, ConversationResult
DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs")
DATA_SUBSCRIPTIONS: HassKey[
list[Callable[[str, ChatLogEventType, dict[str, Any]], None]]
] = HassKey("conversation_chat_log_subscriptions")
LOGGER = logging.getLogger(__name__)
current_chat_log: ContextVar[ChatLog | None] = ContextVar(
@@ -31,6 +36,40 @@ current_chat_log: ContextVar[ChatLog | None] = ContextVar(
)
@callback
def async_subscribe_chat_logs(
hass: HomeAssistant,
callback_func: Callable[[str, ChatLogEventType, dict[str, Any]], None],
) -> Callable[[], None]:
"""Subscribe to all chat logs."""
subscriptions = hass.data.get(DATA_SUBSCRIPTIONS)
if subscriptions is None:
subscriptions = []
hass.data[DATA_SUBSCRIPTIONS] = subscriptions
subscriptions.append(callback_func)
@callback
def unsubscribe() -> None:
"""Unsubscribe from chat logs."""
subscriptions.remove(callback_func)
return unsubscribe
@callback
def _async_notify_subscribers(
hass: HomeAssistant,
conversation_id: str,
event_type: ChatLogEventType,
data: dict[str, Any],
) -> None:
"""Notify subscribers of a chat log event."""
if subscriptions := hass.data.get(DATA_SUBSCRIPTIONS):
for callback_func in subscriptions:
callback_func(conversation_id, event_type, data)
@contextmanager
def async_get_chat_log(
hass: HomeAssistant,
@@ -63,6 +102,8 @@ def async_get_chat_log(
all_chat_logs = {}
hass.data[DATA_CHAT_LOGS] = all_chat_logs
is_new_log = session.conversation_id not in all_chat_logs
if chat_log := all_chat_logs.get(session.conversation_id):
chat_log = replace(chat_log, content=chat_log.content.copy())
else:
@@ -71,6 +112,15 @@ def async_get_chat_log(
if chat_log_delta_listener:
chat_log.delta_listener = chat_log_delta_listener
# Fire CREATED event for new chat logs before any content is added
if is_new_log:
_async_notify_subscribers(
hass,
session.conversation_id,
ChatLogEventType.CREATED,
{"chat_log": chat_log.as_dict()},
)
if user_input is not None:
chat_log.async_add_user_content(UserContent(content=user_input.text))
@@ -84,14 +134,28 @@ def async_get_chat_log(
LOGGER.debug(
"Chat Log opened but no assistant message was added, ignoring update"
)
# If this was a new log but nothing was added, fire DELETED to clean up
if is_new_log:
_async_notify_subscribers(
hass,
session.conversation_id,
ChatLogEventType.DELETED,
{},
)
return
if session.conversation_id not in all_chat_logs:
if is_new_log:
@callback
def do_cleanup() -> None:
"""Handle cleanup."""
all_chat_logs.pop(session.conversation_id)
_async_notify_subscribers(
hass,
session.conversation_id,
ChatLogEventType.DELETED,
{},
)
session.async_on_cleanup(do_cleanup)
@@ -100,6 +164,16 @@ def async_get_chat_log(
all_chat_logs[session.conversation_id] = chat_log
# For new logs, CREATED was already fired before content was added
# For existing logs, fire UPDATED
if not is_new_log:
_async_notify_subscribers(
hass,
session.conversation_id,
ChatLogEventType.UPDATED,
{"chat_log": chat_log.as_dict()},
)
class ConverseError(HomeAssistantError):
"""Error during initialization of conversation.
@@ -129,6 +203,11 @@ class SystemContent:
role: Literal["system"] = field(init=False, default="system")
content: str
created: datetime = field(init=False, default_factory=utcnow)
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the content."""
return {"role": self.role, "content": self.content}
@dataclass(frozen=True)
@@ -138,6 +217,20 @@ class UserContent:
role: Literal["user"] = field(init=False, default="user")
content: str
attachments: list[Attachment] | None = field(default=None)
created: datetime = field(init=False, default_factory=utcnow)
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the content."""
result: dict[str, Any] = {
"role": self.role,
"content": self.content,
"created": self.created,
}
if self.attachments:
result["attachments"] = [
attachment.as_dict() for attachment in self.attachments
]
return result
@dataclass(frozen=True)
@@ -153,6 +246,14 @@ class Attachment:
path: Path
"""Path to the attachment on disk."""
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the attachment."""
return {
"media_content_id": self.media_content_id,
"mime_type": self.mime_type,
"path": str(self.path),
}
@dataclass(frozen=True)
class AssistantContent:
@@ -164,6 +265,22 @@ class AssistantContent:
thinking_content: str | None = None
tool_calls: list[llm.ToolInput] | None = None
native: Any = None
created: datetime = field(init=False, default_factory=utcnow)
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the content."""
result: dict[str, Any] = {
"role": self.role,
"agent_id": self.agent_id,
"created": self.created,
}
if self.content:
result["content"] = self.content
if self.thinking_content:
result["thinking_content"] = self.thinking_content
if self.tool_calls:
result["tool_calls"] = self.tool_calls
return result
@dataclass(frozen=True)
@@ -175,6 +292,18 @@ class ToolResultContent:
tool_call_id: str
tool_name: str
tool_result: JsonObjectType
created: datetime = field(init=False, default_factory=utcnow)
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the content."""
return {
"role": self.role,
"agent_id": self.agent_id,
"tool_call_id": self.tool_call_id,
"tool_name": self.tool_name,
"tool_result": self.tool_result,
"created": self.created,
}
type Content = SystemContent | UserContent | AssistantContent | ToolResultContent
@@ -210,6 +339,16 @@ class ChatLog:
llm_api: llm.APIInstance | None = None
delta_listener: Callable[[ChatLog, dict], None] | None = None
llm_input_provided_index = 0
created: datetime = field(init=False, default_factory=utcnow)
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the chat log."""
return {
"conversation_id": self.conversation_id,
"continue_conversation": self.continue_conversation,
"content": [c.as_dict() for c in self.content],
"created": self.created,
}
@property
def continue_conversation(self) -> bool:
@@ -241,6 +380,12 @@ class ChatLog:
"""Add user content to the log."""
LOGGER.debug("Adding user content: %s", content)
self.content.append(content)
_async_notify_subscribers(
self.hass,
self.conversation_id,
ChatLogEventType.CONTENT_ADDED,
{"content": content.as_dict()},
)
@callback
def async_add_assistant_content_without_tools(
@@ -259,6 +404,12 @@ class ChatLog:
):
raise ValueError("Non-external tool calls not allowed")
self.content.append(content)
_async_notify_subscribers(
self.hass,
self.conversation_id,
ChatLogEventType.CONTENT_ADDED,
{"content": content.as_dict()},
)
async def async_add_assistant_content(
self,
@@ -317,6 +468,14 @@ class ChatLog:
tool_result=tool_result,
)
self.content.append(response_content)
_async_notify_subscribers(
self.hass,
self.conversation_id,
ChatLogEventType.CONTENT_ADDED,
{
"content": response_content.as_dict(),
},
)
yield response_content
async def async_add_delta_content_stream(
@@ -593,6 +752,12 @@ class ChatLog:
self.llm_api = llm_api
self.extra_system_prompt = extra_system_prompt
self.content[0] = SystemContent(content=prompt)
_async_notify_subscribers(
self.hass,
self.conversation_id,
ChatLogEventType.UPDATED,
{"chat_log": self.as_dict()},
)
LOGGER.debug("Prompt: %s", self.content)
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from enum import IntFlag
from enum import IntFlag, StrEnum
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
@@ -30,3 +30,13 @@ class ConversationEntityFeature(IntFlag):
"""Supported features of the conversation entity."""
CONTROL = 1
class ChatLogEventType(StrEnum):
"""Chat log event type."""
INITIAL_STATE = "initial_state"
CREATED = "created"
UPDATED = "updated"
DELETED = "deleted"
CONTENT_ADDED = "content_added"

View File

@@ -12,6 +12,7 @@ from homeassistant.components import http, websocket_api
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.const import MATCH_ALL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.chat_session import async_get_chat_session
from homeassistant.util import language as language_util
from .agent_manager import (
@@ -20,7 +21,8 @@ from .agent_manager import (
async_get_agent,
get_agent_manager,
)
from .const import DATA_COMPONENT
from .chat_log import DATA_CHAT_LOGS, async_get_chat_log, async_subscribe_chat_logs
from .const import DATA_COMPONENT, ChatLogEventType
from .entity import ConversationEntity
from .models import ConversationInput
@@ -35,6 +37,8 @@ def async_setup(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_list_sentences)
websocket_api.async_register_command(hass, websocket_hass_agent_debug)
websocket_api.async_register_command(hass, websocket_hass_agent_language_scores)
websocket_api.async_register_command(hass, websocket_subscribe_chat_log)
websocket_api.async_register_command(hass, websocket_subscribe_chat_log_index)
@websocket_api.websocket_command(
@@ -265,3 +269,114 @@ class ConversationProcessView(http.HomeAssistantView):
)
return self.json(result.as_dict())
@websocket_api.websocket_command(
{
vol.Required("type"): "conversation/chat_log/subscribe",
vol.Required("conversation_id"): str,
}
)
@websocket_api.require_admin
def websocket_subscribe_chat_log(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Subscribe to a chat log."""
msg_id = msg["id"]
subscribed_conversation = msg["conversation_id"]
chat_logs = hass.data.get(DATA_CHAT_LOGS)
if not chat_logs or subscribed_conversation not in chat_logs:
connection.send_error(
msg_id,
websocket_api.ERR_NOT_FOUND,
"Conversation chat log not found",
)
return
@callback
def forward_events(conversation_id: str, event_type: str, data: dict) -> None:
"""Forward chat log events to websocket connection."""
if conversation_id != subscribed_conversation:
return
connection.send_event(
msg_id,
{
"conversation_id": conversation_id,
"event_type": event_type,
"data": data,
},
)
if event_type == ChatLogEventType.DELETED:
unsubscribe()
del connection.subscriptions[msg["id"]]
unsubscribe = async_subscribe_chat_logs(hass, forward_events)
connection.subscriptions[msg["id"]] = unsubscribe
connection.send_result(msg["id"])
with (
async_get_chat_session(hass, subscribed_conversation) as session,
async_get_chat_log(hass, session) as chat_log,
):
connection.send_event(
msg_id,
{
"event_type": ChatLogEventType.INITIAL_STATE,
"data": chat_log.as_dict(),
},
)
@websocket_api.websocket_command(
{
vol.Required("type"): "conversation/chat_log/subscribe_index",
}
)
@websocket_api.require_admin
def websocket_subscribe_chat_log_index(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Subscribe to a chat log."""
msg_id = msg["id"]
@callback
def forward_events(
conversation_id: str, event_type: ChatLogEventType, data: dict
) -> None:
"""Forward chat log events to websocket connection."""
if event_type not in (ChatLogEventType.CREATED, ChatLogEventType.DELETED):
return
connection.send_event(
msg_id,
{
"conversation_id": conversation_id,
"event_type": event_type,
"data": data,
},
)
unsubscribe = async_subscribe_chat_logs(hass, forward_events)
connection.subscriptions[msg["id"]] = unsubscribe
connection.send_result(msg["id"])
chat_logs = hass.data.get(DATA_CHAT_LOGS)
if not chat_logs:
return
connection.send_event(
msg_id,
{
"event_type": ChatLogEventType.INITIAL_STATE,
"data": [c.as_dict() for c in chat_logs.values()],
},
)

View File

@@ -12,7 +12,7 @@ if TYPE_CHECKING:
DOMAIN = ha.DOMAIN
DATA_EXPOSED_ENTITIES: HassKey[ExposedEntities] = HassKey(f"{DOMAIN}.exposed_entites")
DATA_EXPOSED_ENTITIES: HassKey[ExposedEntities] = HassKey(f"{DOMAIN}.exposed_entities")
DATA_STOP_HANDLER = f"{DOMAIN}.stop_handler"
SERVICE_HOMEASSISTANT_STOP: Final = "stop"

View File

@@ -6,16 +6,19 @@
You are a Home Assistant expert and help users with their tasks.
Current time is 15:59:00. Today's date is 2025-06-14.
''',
'created': HAFakeDatetime(2025, 6, 14, 22, 59, tzinfo=datetime.timezone.utc),
'role': 'system',
}),
dict({
'attachments': None,
'content': 'Test prompt',
'created': HAFakeDatetime(2025, 6, 14, 22, 59, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'ai_task.test_task_entity',
'content': 'Mock result',
'created': HAFakeDatetime(2025, 6, 14, 22, 59, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,

View File

@@ -9,16 +9,19 @@
Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.
Current time is 16:00:00. Today's date is 2024-06-03.
''',
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
'role': 'system',
}),
dict({
'attachments': None,
'content': 'Please call the test function',
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.claude_conversation',
'content': None,
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
'native': ThinkingBlock(signature='ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', thinking='', type='thinking'),
'role': 'assistant',
'thinking_content': 'The user asked me to call a test function.Is it a test? What would the function do? Would it violate any privacy or security policies?',
@@ -27,6 +30,7 @@
dict({
'agent_id': 'conversation.claude_conversation',
'content': None,
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'),
'role': 'assistant',
'thinking_content': None,
@@ -35,6 +39,7 @@
dict({
'agent_id': 'conversation.claude_conversation',
'content': 'Certainly, calling it now!',
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
'native': ThinkingBlock(signature='ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', thinking='', type='thinking'),
'role': 'assistant',
'thinking_content': "Okay, let's give it a shot. Will I pass the test?",
@@ -51,6 +56,7 @@
}),
dict({
'agent_id': 'conversation.claude_conversation',
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'toolu_0123456789AbCdEfGhIjKlM',
'tool_name': 'test_tool',
@@ -59,6 +65,7 @@
dict({
'agent_id': 'conversation.claude_conversation',
'content': 'I have successfully called the function',
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -460,11 +467,13 @@
dict({
'attachments': None,
'content': 'ANTHROPIC_MAGIC_STRING_TRIGGER_REDACTED_THINKING_46C9A13E193C177646C7398A98432ECCCE4C1253D5E2D82641AC0E52CC2876CB',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.claude_conversation',
'content': None,
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'),
'role': 'assistant',
'thinking_content': None,
@@ -473,6 +482,7 @@
dict({
'agent_id': 'conversation.claude_conversation',
'content': None,
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'),
'role': 'assistant',
'thinking_content': None,
@@ -481,6 +491,7 @@
dict({
'agent_id': 'conversation.claude_conversation',
'content': 'How can I help you today?',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'),
'role': 'assistant',
'thinking_content': None,
@@ -527,11 +538,13 @@
dict({
'attachments': None,
'content': "What's on the news today?",
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.claude_conversation',
'content': "To get today's news, I'll perform a web search",
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': ThinkingBlock(signature='ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', thinking='', type='thinking'),
'role': 'assistant',
'thinking_content': "The user is asking about today's news, which requires current, real-time information. This is clearly something that requires recent information beyond my knowledge cutoff. I should use the web_search tool to find today's news.",
@@ -548,6 +561,7 @@
}),
dict({
'agent_id': 'conversation.claude_conversation',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'srvtoolu_12345ABC',
'tool_name': 'web_search',
@@ -578,6 +592,7 @@
2. Something incredible happened
Those are the main headlines making news today.
''',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': dict({
'citation_details': list([
dict({

View File

@@ -785,6 +785,7 @@ async def test_extended_thinking(
assert chat_log.content[2].content == "Hello, how can I help you today?"
@freeze_time("2024-05-24 12:00:00")
async def test_redacted_thinking(
hass: HomeAssistant,
mock_config_entry_with_extended_thinking: MockConfigEntry,
@@ -911,6 +912,7 @@ async def test_extended_thinking_tool_call(
assert mock_create.mock_calls[1][2]["messages"] == snapshot
@freeze_time("2025-10-31 12:00:00")
async def test_web_search(
hass: HomeAssistant,
mock_config_entry_with_web_search: MockConfigEntry,

View File

@@ -493,6 +493,7 @@
'data': dict({
'chat_log_delta': dict({
'agent_id': 'test-agent',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'test_tool_id',
'tool_name': 'test_tool',

View File

@@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Generator
from typing import Any
from unittest.mock import ANY, AsyncMock, Mock, patch
from freezegun import freeze_time
from hassil.recognize import Intent, IntentData, RecognizeResult
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -1637,6 +1638,7 @@ async def test_pipeline_language_used_instead_of_conversation_language(
),
],
)
@freeze_time("2025-10-31 12:00:00")
async def test_chat_log_tts_streaming(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,

View File

@@ -8,6 +8,7 @@
dict({
'agent_id': 'mock-agent-id',
'content': None,
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': object(
),
'role': 'assistant',
@@ -21,6 +22,7 @@
dict({
'agent_id': 'mock-agent-id',
'content': 'Test',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -37,6 +39,7 @@
}),
dict({
'agent_id': 'mock-agent-id',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'mock-tool-call-id',
'tool_name': 'test_tool',
@@ -49,6 +52,7 @@
dict({
'agent_id': 'mock-agent-id',
'content': 'Test',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -61,6 +65,7 @@
dict({
'agent_id': 'mock-agent-id',
'content': 'Test',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -69,6 +74,7 @@
dict({
'agent_id': 'mock-agent-id',
'content': 'Test 2',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -81,6 +87,7 @@
dict({
'agent_id': 'mock-agent-id',
'content': None,
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -97,6 +104,7 @@
}),
dict({
'agent_id': 'mock-agent-id',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'mock-tool-call-id',
'tool_name': 'test_tool',
@@ -109,6 +117,7 @@
dict({
'agent_id': 'mock-agent-id',
'content': 'Test',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -125,6 +134,7 @@
}),
dict({
'agent_id': 'mock-agent-id',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'mock-tool-call-id',
'tool_name': 'test_tool',
@@ -137,6 +147,7 @@
dict({
'agent_id': 'mock-agent-id',
'content': 'Test',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -153,6 +164,7 @@
}),
dict({
'agent_id': 'mock-agent-id',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'mock-tool-call-id',
'tool_name': 'test_tool',
@@ -161,6 +173,7 @@
dict({
'agent_id': 'mock-agent-id',
'content': 'Test 2',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -173,6 +186,7 @@
dict({
'agent_id': 'mock-agent-id',
'content': None,
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -197,6 +211,7 @@
}),
dict({
'agent_id': 'mock-agent-id',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'mock-tool-call-id',
'tool_name': 'test_tool',
@@ -204,6 +219,7 @@
}),
dict({
'agent_id': 'mock-agent-id',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'mock-tool-call-id-2',
'tool_name': 'test_tool',
@@ -216,6 +232,7 @@
dict({
'agent_id': 'mock-agent-id',
'content': None,
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': 'Test Thinking',
@@ -228,6 +245,7 @@
dict({
'agent_id': 'mock-agent-id',
'content': 'Test',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': 'Test Thinking',
@@ -240,6 +258,7 @@
dict({
'agent_id': 'mock-agent-id',
'content': None,
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': dict({
'type': 'test',
'value': 'Test Native',

View File

@@ -2,8 +2,11 @@
from dataclasses import asdict
from datetime import timedelta
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
from freezegun import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
import voluptuous as vol
@@ -16,7 +19,12 @@ from homeassistant.components.conversation import (
UserContent,
async_get_chat_log,
)
from homeassistant.components.conversation.chat_log import DATA_CHAT_LOGS
from homeassistant.components.conversation.chat_log import (
DATA_CHAT_LOGS,
Attachment,
ChatLogEventType,
async_subscribe_chat_logs,
)
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import chat_session, llm
@@ -398,6 +406,7 @@ async def test_extra_systen_prompt(
assert chat_log.content[0].content.endswith(extra_system_prompt2)
@freeze_time("2025-10-31 18:00:00")
@pytest.mark.parametrize(
"prerun_tool_tasks",
[
@@ -484,6 +493,7 @@ async def test_tool_call(
)
@freeze_time("2025-10-31 12:00:00")
async def test_tool_call_exception(
hass: HomeAssistant,
mock_conversation_input: ConversationInput,
@@ -536,6 +546,7 @@ async def test_tool_call_exception(
)
@freeze_time("2025-10-31 12:00:00")
@pytest.mark.parametrize(
"deltas",
[
@@ -841,3 +852,171 @@ async def test_chat_log_continue_conversation(
)
)
assert chat_log.continue_conversation is True
@freeze_time("2025-10-31 12:00:00")
async def test_chat_log_subscription(
hass: HomeAssistant,
mock_conversation_input: ConversationInput,
) -> None:
"""Test comprehensive chat log subscription functionality."""
# Track all events received
received_events = []
def event_callback(
conversation_id: str, event_type: ChatLogEventType, data: dict[str, Any]
) -> None:
"""Track received events."""
received_events.append((conversation_id, event_type, data))
# Subscribe to chat log events
unsubscribe = async_subscribe_chat_logs(hass, event_callback)
with (
chat_session.async_get_chat_session(hass) as session,
async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
):
conversation_id = session.conversation_id
# Test adding different types of content and verify events are sent
chat_log.async_add_user_content(
UserContent(
content="Check this image",
attachments=[
Attachment(
mime_type="image/jpeg",
media_content_id="media-source://bla",
path=Path("test_image.jpg"),
)
],
)
)
# Check user content with attachments event
assert received_events[-1][1] == ChatLogEventType.CONTENT_ADDED
user_event = received_events[-1][2]["content"]
assert user_event["content"] == "Check this image"
assert len(user_event["attachments"]) == 1
assert user_event["attachments"][0]["mime_type"] == "image/jpeg"
chat_log.async_add_assistant_content_without_tools(
AssistantContent(
agent_id="test-agent", content="Hello! How can I help you?"
)
)
# Check basic assistant content event
assert received_events[-1][1] == ChatLogEventType.CONTENT_ADDED
basic_event = received_events[-1][2]["content"]
assert basic_event["content"] == "Hello! How can I help you?"
assert basic_event["agent_id"] == "test-agent"
chat_log.async_add_assistant_content_without_tools(
AssistantContent(
agent_id="test-agent",
content="Let me think about that...",
thinking_content="I need to analyze the user's request carefully.",
)
)
# Check assistant content with thinking event
assert received_events[-1][1] == ChatLogEventType.CONTENT_ADDED
thinking_event = received_events[-1][2]["content"]
assert (
thinking_event["thinking_content"]
== "I need to analyze the user's request carefully."
)
chat_log.async_add_assistant_content_without_tools(
AssistantContent(
agent_id="test-agent",
content="Here's some data:",
native={"type": "chart", "data": [1, 2, 3, 4, 5]},
)
)
# Check assistant content with native event
assert received_events[-1][1] == ChatLogEventType.CONTENT_ADDED
native_event = received_events[-1][2]["content"]
assert native_event["content"] == "Here's some data:"
assert native_event["agent_id"] == "test-agent"
chat_log.async_add_assistant_content_without_tools(
ToolResultContent(
agent_id="test-agent",
tool_call_id="test-tool-call-123",
tool_name="test_tool",
tool_result="Tool execution completed successfully",
)
)
# Check tool result content event
assert received_events[-1][1] == ChatLogEventType.CONTENT_ADDED
tool_result_event = received_events[-1][2]["content"]
assert tool_result_event["tool_name"] == "test_tool"
assert (
tool_result_event["tool_result"] == "Tool execution completed successfully"
)
chat_log.async_add_assistant_content_without_tools(
AssistantContent(
agent_id="test-agent",
content="I'll call an external service",
tool_calls=[
llm.ToolInput(
id="external-tool-call-123",
tool_name="external_api_call",
tool_args={"endpoint": "https://api.example.com/data"},
external=True,
)
],
)
)
# Check external tool call event
assert received_events[-1][1] == ChatLogEventType.CONTENT_ADDED
external_tool_event = received_events[-1][2]["content"]
assert len(external_tool_event["tool_calls"]) == 1
assert external_tool_event["tool_calls"][0].tool_name == "external_api_call"
# Verify we received the expected events
# Should have: 1 CREATED event + 7 CONTENT_ADDED events
assert len(received_events) == 8
# Check the first event is CREATED
assert received_events[0][1] == ChatLogEventType.CREATED
assert received_events[0][2]["chat_log"]["conversation_id"] == conversation_id
# Check the second event is CONTENT_ADDED (from mock_conversation_input)
assert received_events[1][1] == ChatLogEventType.CONTENT_ADDED
assert received_events[1][0] == conversation_id
# Test cleanup functionality
assert conversation_id in hass.data[chat_session.DATA_CHAT_SESSION]
# Set the last updated to be older than the timeout
hass.data[chat_session.DATA_CHAT_SESSION][conversation_id].last_updated = (
dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT
)
async_fire_time_changed(
hass,
dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT * 2 + timedelta(seconds=1),
)
# Check that DELETED event was sent
assert received_events[-1][1] == ChatLogEventType.DELETED
assert received_events[-1][0] == conversation_id
# Test that unsubscribing stops receiving events
events_before_unsubscribe = len(received_events)
unsubscribe()
# Create a new session and add content - should not receive events
with (
chat_session.async_get_chat_session(hass) as session2,
async_get_chat_log(hass, session2, mock_conversation_input) as chat_log2,
):
chat_log2.async_add_assistant_content_without_tools(
AssistantContent(
agent_id="test-agent", content="This should not be received"
)
)
# Verify no new events were received after unsubscribing
assert len(received_events) == events_before_unsubscribe

View File

@@ -1,23 +1,36 @@
"""The tests for the HTTP API of the Conversation component."""
from datetime import timedelta
from http import HTTPStatus
from typing import Any
from unittest.mock import patch
from freezegun import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.conversation import async_get_agent
from homeassistant.components.conversation import (
AssistantContent,
ConversationInput,
async_get_agent,
async_get_chat_log,
)
from homeassistant.components.conversation.const import HOME_ASSISTANT_AGENT
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.const import ATTR_FRIENDLY_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar, entity_registry as er, intent
from homeassistant.helpers import (
area_registry as ar,
chat_session,
entity_registry as er,
intent,
)
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
from . import MockAgent
from tests.common import async_mock_service
from tests.common import MockUser, async_fire_time_changed, async_mock_service
from tests.typing import ClientSessionGenerator, WebSocketGenerator
AGENT_ID_OPTIONS = [
@@ -590,3 +603,318 @@ async def test_ws_hass_language_scores_with_filter(
# GB English should be preferred
result = msg["result"]
assert result["preferred_language"] == "en-GB"
async def test_ws_chat_log_index_subscription(
hass: HomeAssistant,
init_components,
mock_conversation_input: ConversationInput,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that we can subscribe to chat logs."""
client = await hass_ws_client(hass)
with freeze_time():
now = utcnow().isoformat()
with (
chat_session.async_get_chat_session(hass) as session,
async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
):
before_sub_conversation_id = session.conversation_id
chat_log.async_add_assistant_content_without_tools(
AssistantContent("test-agent-id", "I hear you")
)
await client.send_json_auto_id(
{"type": "conversation/chat_log/subscribe_index"}
)
msg = await client.receive_json()
assert msg["success"]
event_id = msg["id"]
# 1. The INITIAL_STATE event
msg = await client.receive_json()
assert msg == {
"type": "event",
"id": event_id,
"event": {
"event_type": "initial_state",
"data": [
{
"conversation_id": before_sub_conversation_id,
"continue_conversation": False,
"created": now,
"content": [
{"role": "system", "content": ""},
{"role": "user", "content": "Hello", "created": now},
{
"role": "assistant",
"agent_id": "test-agent-id",
"content": "I hear you",
"created": now,
},
],
}
],
},
}
with (
chat_session.async_get_chat_session(hass) as session,
async_get_chat_log(hass, session, mock_conversation_input),
):
conversation_id = session.conversation_id
# We should receive 2 events for this newly created chat:
# 1. The CREATED event (fired before content is added)
msg = await client.receive_json()
assert msg == {
"type": "event",
"id": event_id,
"event": {
"conversation_id": conversation_id,
"event_type": "created",
"data": {
"chat_log": {
"conversation_id": conversation_id,
"continue_conversation": False,
"created": now,
"content": [{"role": "system", "content": ""}],
}
},
},
}
# 2. The DELETED event (since no assistant message was added)
msg = await client.receive_json()
assert msg == {
"type": "event",
"id": event_id,
"event": {
"conversation_id": conversation_id,
"event_type": "deleted",
"data": {},
},
}
# Trigger session cleanup
with patch(
"homeassistant.helpers.chat_session.CONVERSATION_TIMEOUT",
timedelta(0),
):
async_fire_time_changed(hass, fire_all=True)
# 3. The DELETED event of before sub conversation
msg = await client.receive_json()
assert msg == {
"type": "event",
"id": event_id,
"event": {
"conversation_id": before_sub_conversation_id,
"event_type": "deleted",
"data": {},
},
}
async def test_ws_chat_log_index_subscription_requires_admin(
hass: HomeAssistant,
init_components,
hass_ws_client: WebSocketGenerator,
hass_admin_user: MockUser,
) -> None:
"""Test that chat log subscription requires admin access."""
# Create a non-admin user
hass_admin_user.groups = []
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "conversation/chat_log/subscribe_index",
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "unauthorized"
async def test_ws_chat_log_subscription(
hass: HomeAssistant,
init_components,
mock_conversation_input: ConversationInput,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that we can subscribe to chat logs."""
client = await hass_ws_client(hass)
with freeze_time():
now = utcnow().isoformat()
with (
chat_session.async_get_chat_session(hass) as session,
async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
):
conversation_id = session.conversation_id
chat_log.async_add_assistant_content_without_tools(
AssistantContent("test-agent-id", "I hear you")
)
await client.send_json_auto_id(
{
"type": "conversation/chat_log/subscribe",
"conversation_id": conversation_id,
}
)
msg = await client.receive_json()
assert msg["success"]
event_id = msg["id"]
# 1. The INITIAL_STATE event (fired before content is added)
msg = await client.receive_json()
assert msg == {
"type": "event",
"id": event_id,
"event": {
"event_type": "initial_state",
"data": {
"conversation_id": conversation_id,
"continue_conversation": False,
"created": now,
"content": [
{"role": "system", "content": ""},
{"role": "user", "content": "Hello", "created": now},
{
"role": "assistant",
"agent_id": "test-agent-id",
"content": "I hear you",
"created": now,
},
],
},
},
}
with (
chat_session.async_get_chat_session(hass, conversation_id) as session,
async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
):
chat_log.async_add_assistant_content_without_tools(
AssistantContent("test-agent-id", "I still hear you")
)
# 2. The user input content added event
msg = await client.receive_json()
assert msg == {
"type": "event",
"id": event_id,
"event": {
"conversation_id": conversation_id,
"event_type": "content_added",
"data": {
"content": {
"content": "Hello",
"role": "user",
"created": now,
},
},
},
}
# 3. The assistant input content added event
msg = await client.receive_json()
assert msg == {
"type": "event",
"id": event_id,
"event": {
"conversation_id": conversation_id,
"event_type": "content_added",
"data": {
"content": {
"agent_id": "test-agent-id",
"content": "I still hear you",
"role": "assistant",
"created": now,
},
},
},
}
# Forward time to mimic auto-cleanup
# 4. The UPDATED event (since no assistant message was added)
msg = await client.receive_json()
assert msg == {
"type": "event",
"id": event_id,
"event": {
"conversation_id": conversation_id,
"event_type": "updated",
"data": {
"chat_log": {
"continue_conversation": False,
"conversation_id": conversation_id,
"created": now,
"content": [
{
"content": "",
"role": "system",
},
{
"content": "Hello",
"role": "user",
"created": now,
},
{
"agent_id": "test-agent-id",
"content": "I hear you",
"role": "assistant",
"created": now,
},
{
"content": "Hello",
"role": "user",
"created": now,
},
{
"agent_id": "test-agent-id",
"content": "I still hear you",
"role": "assistant",
"created": now,
},
],
},
},
},
}
# Trigger session cleanup
with patch(
"homeassistant.helpers.chat_session.CONVERSATION_TIMEOUT",
timedelta(0),
):
async_fire_time_changed(hass, fire_all=True)
# 5. The DELETED event
msg = await client.receive_json()
assert msg == {
"type": "event",
"id": event_id,
"event": {
"conversation_id": conversation_id,
"event_type": "deleted",
"data": {},
},
}
# Subscribing now will fail
await client.send_json_auto_id(
{
"type": "conversation/chat_log/subscribe",
"conversation_id": conversation_id,
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "not_found"

View File

@@ -108,11 +108,13 @@
dict({
'attachments': None,
'content': 'hello',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.gpt_3_5_turbo',
'content': 'Hello, how can I help you?',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -125,11 +127,13 @@
dict({
'attachments': None,
'content': 'Please call the test function',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.gpt_3_5_turbo',
'content': None,
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -146,6 +150,7 @@
}),
dict({
'agent_id': 'conversation.gpt_3_5_turbo',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'call_call_1',
'tool_name': 'test_tool',
@@ -154,6 +159,7 @@
dict({
'agent_id': 'conversation.gpt_3_5_turbo',
'content': 'I have successfully called the function',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,

View File

@@ -39,11 +39,13 @@
dict({
'attachments': None,
'content': 'Please call the test function',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.openai_conversation',
'content': None,
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': 'Thinking',
@@ -52,6 +54,7 @@
dict({
'agent_id': 'conversation.openai_conversation',
'content': None,
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': ResponseReasoningItem(id='rs_A', summary=[], type='reasoning', content=None, encrypted_content='AAABBB', status=None),
'role': 'assistant',
'thinking_content': 'Thinking more',
@@ -60,6 +63,7 @@
dict({
'agent_id': 'conversation.openai_conversation',
'content': None,
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -76,6 +80,7 @@
}),
dict({
'agent_id': 'conversation.openai_conversation',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'call_call_1',
'tool_name': 'test_tool',
@@ -84,6 +89,7 @@
dict({
'agent_id': 'conversation.openai_conversation',
'content': None,
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -100,6 +106,7 @@
}),
dict({
'agent_id': 'conversation.openai_conversation',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'call_call_2',
'tool_name': 'test_tool',
@@ -108,6 +115,7 @@
dict({
'agent_id': 'conversation.openai_conversation',
'content': 'Cool',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -171,11 +179,13 @@
dict({
'attachments': None,
'content': 'Please call the test function',
'created': HAFakeDatetime(2025, 10, 31, 18, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.openai_conversation',
'content': None,
'created': HAFakeDatetime(2025, 10, 31, 18, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
@@ -192,6 +202,7 @@
}),
dict({
'agent_id': 'conversation.openai_conversation',
'created': HAFakeDatetime(2025, 10, 31, 18, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'call_call_1',
'tool_name': 'test_tool',
@@ -200,6 +211,7 @@
dict({
'agent_id': 'conversation.openai_conversation',
'content': 'Cool',
'created': HAFakeDatetime(2025, 10, 31, 18, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,

View File

@@ -2,6 +2,7 @@
from unittest.mock import AsyncMock, patch
from freezegun import freeze_time
import httpx
from openai import AuthenticationError, RateLimitError
from openai.types.responses import (
@@ -239,6 +240,7 @@ async def test_conversation_agent(
assert agent.supported_languages == "*"
@freeze_time("2025-10-31 12:00:00")
async def test_function_call(
hass: HomeAssistant,
mock_config_entry_with_reasoning_model: MockConfigEntry,
@@ -298,6 +300,7 @@ async def test_function_call(
assert mock_create_stream.call_args.kwargs["input"][1:] == snapshot
@freeze_time("2025-10-31 18:00:00")
async def test_function_call_without_reasoning(
hass: HomeAssistant,
mock_config_entry_with_assist: MockConfigEntry,