diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 736bf128e6088b..9629bf7c4f94e8 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -7,6 +7,7 @@ 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 @@ 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 @@ ) +@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 do_cleanup() -> None: 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 @@ def async_add_user_content(self, content: UserContent) -> None: """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 @@ def async_add_assistant_content_without_tools( ): 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 @@ async def async_add_assistant_content( 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 @@ async def async_provide_llm_data( 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) diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index e1029de99180c9..e4da2170d64a85 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -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" diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 9d3eb35a7e352f..7ad6b2d04e142b 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -12,6 +12,7 @@ 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 @@ 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 @@ async def post(self, request: web.Request, data: dict[str, str]) -> web.Response ) 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()], + }, + ) diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py index 7fad6728a744be..3ca8a14cce7e78 100644 --- a/homeassistant/components/homeassistant/const.py +++ b/homeassistant/components/homeassistant/const.py @@ -12,7 +12,7 @@ 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" diff --git a/tests/components/ai_task/snapshots/test_task.ambr b/tests/components/ai_task/snapshots/test_task.ambr index 6986c12f8b7234..cd6d38efa0343f 100644 --- a/tests/components/ai_task/snapshots/test_task.ambr +++ b/tests/components/ai_task/snapshots/test_task.ambr @@ -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, diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 1e6158006074cb..df5e2b3f5accb6 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -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({ diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 06d31d415e5c3b..1556f537b73fcc 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -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 @@ def completion_result(*args, messages, **kwargs): 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, diff --git a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr index e92f3aec3fb484..7c39da213da157 100644 --- a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr +++ b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr @@ -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', diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index b2ee4e9c5e3e2e..a25848061ab5b2 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -4,6 +4,7 @@ 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, diff --git a/tests/components/conversation/snapshots/test_chat_log.ambr b/tests/components/conversation/snapshots/test_chat_log.ambr index 787009ba61441a..2c0dc6d996d201 100644 --- a/tests/components/conversation/snapshots/test_chat_log.ambr +++ b/tests/components/conversation/snapshots/test_chat_log.ambr @@ -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', diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 3fc13e93508ee3..d6856267885b3d 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -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 @@ 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 diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index 24fc4d1b135e87..ba858f6c28e42c 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -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" diff --git a/tests/components/open_router/snapshots/test_conversation.ambr b/tests/components/open_router/snapshots/test_conversation.ambr index 19b5785a9eb692..7182559467cee6 100644 --- a/tests/components/open_router/snapshots/test_conversation.ambr +++ b/tests/components/open_router/snapshots/test_conversation.ambr @@ -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, diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 473d32a53f81c8..b043395a57680a 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -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, diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index a53644d6f5b44a..e3d862b8f3fcb9 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -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,