From 39d7dbc6631fb370fa8cdcbde43efe9c82261566 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 25 Oct 2025 22:36:31 -0400 Subject: [PATCH 1/7] Add websocket command to interact with chat logs --- .../components/conversation/chat_log.py | 152 ++++++++- .../components/conversation/const.py | 12 +- homeassistant/components/conversation/http.py | 107 ++++++- .../components/homeassistant/const.py | 2 +- .../components/conversation/test_chat_log.py | 174 +++++++++- tests/components/conversation/test_http.py | 301 +++++++++++++++++- 6 files changed, 739 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 736bf128e6088b..6492392e25f343 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -20,10 +20,13 @@ 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 +34,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 +100,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 +110,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 +132,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 +162,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. @@ -130,6 +202,10 @@ class SystemContent: role: Literal["system"] = field(init=False, default="system") content: str + def as_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the content.""" + return {"role": self.role, "content": self.content} + @dataclass(frozen=True) class UserContent: @@ -139,6 +215,15 @@ class UserContent: content: str attachments: list[Attachment] | None = field(default=None) + def as_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the content.""" + result: dict[str, Any] = {"role": self.role, "content": self.content} + if self.attachments: + result["attachments"] = [ + attachment.as_dict() for attachment in self.attachments + ] + return result + @dataclass(frozen=True) class Attachment: @@ -153,6 +238,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: @@ -165,6 +258,17 @@ class AssistantContent: tool_calls: list[llm.ToolInput] | None = None native: Any = None + 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} + 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) class ToolResultContent: @@ -176,6 +280,16 @@ class ToolResultContent: tool_name: str tool_result: JsonObjectType + 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, + } + type Content = SystemContent | UserContent | AssistantContent | ToolResultContent @@ -211,6 +325,14 @@ class ChatLog: delta_listener: Callable[[ChatLog, dict], None] | None = None llm_input_provided_index = 0 + 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], + } + @property def continue_conversation(self) -> bool: """Return whether the conversation should continue.""" @@ -241,6 +363,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 +387,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 +451,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 +735,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 8f6404bdc00293..b939b3b8c50b76 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 @@ -34,3 +34,13 @@ class ConversationEntityFeature(IntFlag): METADATA_CUSTOM_SENTENCE = "hass_custom_sentence" METADATA_CUSTOM_FILE = "hass_custom_file" + + +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..2558116ec6e32b 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,104 @@ 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"] + + @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/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 3fc13e93508ee3..7114eb07d9b574 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -2,6 +2,8 @@ from dataclasses import asdict from datetime import timedelta +from pathlib import Path +from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest @@ -16,7 +18,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 @@ -841,3 +848,168 @@ async def test_chat_log_continue_conversation( ) ) assert chat_log.continue_conversation is True + + +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(event_type: ChatLogEventType, data: dict[str, Any]) -> None: + """Track received events.""" + received_events.append((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][0] == ChatLogEventType.CONTENT_ADDED + user_event = received_events[-1][1]["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][0] == ChatLogEventType.CONTENT_ADDED + basic_event = received_events[-1][1]["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][0] == ChatLogEventType.CONTENT_ADDED + thinking_event = received_events[-1][1]["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][0] == ChatLogEventType.CONTENT_ADDED + native_event = received_events[-1][1]["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][0] == ChatLogEventType.CONTENT_ADDED + tool_result_event = received_events[-1][1]["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][0] == ChatLogEventType.CONTENT_ADDED + external_tool_event = received_events[-1][1]["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][0] == ChatLogEventType.CREATED + assert received_events[0][1]["chat_log"]["conversation_id"] == conversation_id + + # Check the second event is CONTENT_ADDED (from mock_conversation_input) + assert received_events[1][0] == ChatLogEventType.CONTENT_ADDED + assert received_events[1][1]["conversation_id"] == 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][0] == ChatLogEventType.DELETED + assert received_events[-1][1]["conversation_id"] == 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..2568d485f51164 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -1,5 +1,6 @@ """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 @@ -7,17 +8,27 @@ 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 . 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 +601,287 @@ 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 ( + 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, + "content": [ + {"role": "system", "content": ""}, + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "agent_id": "test-agent-id", + "content": "I hear you", + }, + ], + } + ], + }, + } + + 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, + "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 ( + 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, + "content": [ + {"role": "system", "content": ""}, + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "agent_id": "test-agent-id", + "content": "I hear you", + }, + ], + }, + }, + } + + 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", + }, + }, + }, + } + + # 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", + }, + }, + }, + } + + # 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": { + "content": [ + { + "content": "", + "role": "system", + }, + { + "content": "Hello", + "role": "user", + }, + { + "agent_id": "test-agent-id", + "content": "I hear you", + "role": "assistant", + }, + { + "content": "Hello", + "role": "user", + }, + { + "agent_id": "test-agent-id", + "content": "I still hear you", + "role": "assistant", + }, + ], + "continue_conversation": False, + "conversation_id": conversation_id, + }, + }, + }, + } + + # 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": {}, + }, + } From a3245a931f771d7c18821902731cf632dc5fe5a1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Oct 2025 14:06:40 -0400 Subject: [PATCH 2/7] More timestamps --- .../components/conversation/chat_log.py | 21 +++++++++++++++++-- homeassistant/components/conversation/http.py | 2 ++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 6492392e25f343..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,6 +17,7 @@ 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 @@ -201,6 +203,7 @@ 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.""" @@ -214,10 +217,15 @@ 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} + 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 @@ -257,10 +265,15 @@ 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} + 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: @@ -279,6 +292,7 @@ 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.""" @@ -288,6 +302,7 @@ def as_dict(self) -> dict[str, Any]: "tool_call_id": self.tool_call_id, "tool_name": self.tool_name, "tool_result": self.tool_result, + "created": self.created, } @@ -324,6 +339,7 @@ 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.""" @@ -331,6 +347,7 @@ def as_dict(self) -> dict[str, Any]: "conversation_id": self.conversation_id, "continue_conversation": self.continue_conversation, "content": [c.as_dict() for c in self.content], + "created": self.created, } @property diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 2558116ec6e32b..4bae9d48171fb3 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -287,6 +287,8 @@ def websocket_subscribe_chat_log( msg_id = msg["id"] subscribed_conversation = msg["conversation_id"] + # TODO: if chat log doesn't exist, raise NOT_FOUND to prevent creating one + @callback def forward_events(conversation_id: str, event_type: str, data: dict) -> None: """Forward chat log events to websocket connection.""" From 86d47b6bd9a19b6a5099185b73945db6a579dad8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 27 Oct 2025 11:07:27 -0700 Subject: [PATCH 3/7] Add not found error --- homeassistant/components/conversation/http.py | 10 ++++- tests/components/conversation/test_http.py | 38 +++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 4bae9d48171fb3..7ad6b2d04e142b 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -287,7 +287,15 @@ def websocket_subscribe_chat_log( msg_id = msg["id"] subscribed_conversation = msg["conversation_id"] - # TODO: if chat log doesn't exist, raise NOT_FOUND to prevent creating one + 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: diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index 2568d485f51164..bc8704fa4b128c 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import patch +from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion @@ -25,6 +26,7 @@ intent, ) from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow from . import MockAgent @@ -603,6 +605,7 @@ async def test_ws_hass_language_scores_with_filter( assert result["preferred_language"] == "en-GB" +@freeze_time() async def test_ws_chat_log_index_subscription( hass: HomeAssistant, init_components, @@ -610,6 +613,8 @@ async def test_ws_chat_log_index_subscription( hass_ws_client: WebSocketGenerator, ) -> None: """Test that we can subscribe to chat logs.""" + now = utcnow().isoformat() + client = await hass_ws_client(hass) with ( @@ -637,13 +642,15 @@ async def test_ws_chat_log_index_subscription( { "conversation_id": before_sub_conversation_id, "continue_conversation": False, + "created": now, "content": [ {"role": "system", "content": ""}, - {"role": "user", "content": "Hello"}, + {"role": "user", "content": "Hello", "created": now}, { "role": "assistant", "agent_id": "test-agent-id", "content": "I hear you", + "created": now, }, ], } @@ -670,6 +677,7 @@ async def test_ws_chat_log_index_subscription( "chat_log": { "conversation_id": conversation_id, "continue_conversation": False, + "created": now, "content": [{"role": "system", "content": ""}], } }, @@ -730,6 +738,7 @@ async def test_ws_chat_log_index_subscription_requires_admin( assert msg["error"]["code"] == "unauthorized" +@freeze_time() async def test_ws_chat_log_subscription( hass: HomeAssistant, init_components, @@ -737,6 +746,7 @@ async def test_ws_chat_log_subscription( hass_ws_client: WebSocketGenerator, ) -> None: """Test that we can subscribe to chat logs.""" + now = utcnow().isoformat() client = await hass_ws_client(hass) with ( @@ -768,13 +778,15 @@ async def test_ws_chat_log_subscription( "data": { "conversation_id": conversation_id, "continue_conversation": False, + "created": now, "content": [ {"role": "system", "content": ""}, - {"role": "user", "content": "Hello"}, + {"role": "user", "content": "Hello", "created": now}, { "role": "assistant", "agent_id": "test-agent-id", "content": "I hear you", + "created": now, }, ], }, @@ -801,6 +813,7 @@ async def test_ws_chat_log_subscription( "content": { "content": "Hello", "role": "user", + "created": now, }, }, }, @@ -819,6 +832,7 @@ async def test_ws_chat_log_subscription( "agent_id": "test-agent-id", "content": "I still hear you", "role": "assistant", + "created": now, }, }, }, @@ -836,6 +850,9 @@ async def test_ws_chat_log_subscription( "event_type": "updated", "data": { "chat_log": { + "continue_conversation": False, + "conversation_id": conversation_id, + "created": now, "content": [ { "content": "", @@ -844,24 +861,26 @@ async def test_ws_chat_log_subscription( { "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, }, ], - "continue_conversation": False, - "conversation_id": conversation_id, }, }, }, @@ -885,3 +904,14 @@ async def test_ws_chat_log_subscription( "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" From 5fd39f0b060c9951a609434663ca930953be4289 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Nov 2025 14:05:45 -0500 Subject: [PATCH 4/7] Fix tests --- .../snapshots/test_conversation.ambr | 15 + .../components/anthropic/test_conversation.py | 2 + .../conversation/snapshots/test_chat_log.ambr | 19 + .../components/conversation/test_chat_log.py | 47 +- tests/components/conversation/test_http.py | 483 +++++++++--------- .../snapshots/test_conversation.ambr | 6 + .../snapshots/test_conversation.ambr | 12 + .../openai_conversation/test_conversation.py | 3 + 8 files changed, 327 insertions(+), 260 deletions(-) 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 63ea441fab4850..b259b988424a48 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -516,6 +516,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, @@ -618,6 +619,7 @@ async def test_extended_thinking_tool_call( assert mock_create_stream.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/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 7114eb07d9b574..d6856267885b3d 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -6,6 +6,7 @@ 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 @@ -405,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", [ @@ -491,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, @@ -543,6 +546,7 @@ async def test_tool_call_exception( ) +@freeze_time("2025-10-31 12:00:00") @pytest.mark.parametrize( "deltas", [ @@ -850,6 +854,7 @@ 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, @@ -859,9 +864,11 @@ async def test_chat_log_subscription( # Track all events received received_events = [] - def event_callback(event_type: ChatLogEventType, data: dict[str, Any]) -> None: + def event_callback( + conversation_id: str, event_type: ChatLogEventType, data: dict[str, Any] + ) -> None: """Track received events.""" - received_events.append((event_type, data)) + received_events.append((conversation_id, event_type, data)) # Subscribe to chat log events unsubscribe = async_subscribe_chat_logs(hass, event_callback) @@ -886,8 +893,8 @@ def event_callback(event_type: ChatLogEventType, data: dict[str, Any]) -> None: ) ) # Check user content with attachments event - assert received_events[-1][0] == ChatLogEventType.CONTENT_ADDED - user_event = received_events[-1][1]["content"] + 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" @@ -898,8 +905,8 @@ def event_callback(event_type: ChatLogEventType, data: dict[str, Any]) -> None: ) ) # Check basic assistant content event - assert received_events[-1][0] == ChatLogEventType.CONTENT_ADDED - basic_event = received_events[-1][1]["content"] + 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" @@ -911,8 +918,8 @@ def event_callback(event_type: ChatLogEventType, data: dict[str, Any]) -> None: ) ) # Check assistant content with thinking event - assert received_events[-1][0] == ChatLogEventType.CONTENT_ADDED - thinking_event = received_events[-1][1]["content"] + 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." @@ -926,8 +933,8 @@ def event_callback(event_type: ChatLogEventType, data: dict[str, Any]) -> None: ) ) # Check assistant content with native event - assert received_events[-1][0] == ChatLogEventType.CONTENT_ADDED - native_event = received_events[-1][1]["content"] + 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" @@ -940,8 +947,8 @@ def event_callback(event_type: ChatLogEventType, data: dict[str, Any]) -> None: ) ) # Check tool result content event - assert received_events[-1][0] == ChatLogEventType.CONTENT_ADDED - tool_result_event = received_events[-1][1]["content"] + 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" @@ -962,8 +969,8 @@ def event_callback(event_type: ChatLogEventType, data: dict[str, Any]) -> None: ) ) # Check external tool call event - assert received_events[-1][0] == ChatLogEventType.CONTENT_ADDED - external_tool_event = received_events[-1][1]["content"] + 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" @@ -972,12 +979,12 @@ def event_callback(event_type: ChatLogEventType, data: dict[str, Any]) -> None: assert len(received_events) == 8 # Check the first event is CREATED - assert received_events[0][0] == ChatLogEventType.CREATED - assert received_events[0][1]["chat_log"]["conversation_id"] == conversation_id + 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][0] == ChatLogEventType.CONTENT_ADDED - assert received_events[1][1]["conversation_id"] == conversation_id + 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] @@ -993,8 +1000,8 @@ def event_callback(event_type: ChatLogEventType, data: dict[str, Any]) -> None: ) # Check that DELETED event was sent - assert received_events[-1][0] == ChatLogEventType.DELETED - assert received_events[-1][1]["conversation_id"] == conversation_id + 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) diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index bc8704fa4b128c..ba858f6c28e42c 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -605,7 +605,6 @@ async def test_ws_hass_language_scores_with_filter( assert result["preferred_language"] == "en-GB" -@freeze_time() async def test_ws_chat_log_index_subscription( hass: HomeAssistant, init_components, @@ -613,107 +612,110 @@ async def test_ws_chat_log_index_subscription( hass_ws_client: WebSocketGenerator, ) -> None: """Test that we can subscribe to chat logs.""" - now = utcnow().isoformat() - client = await hass_ws_client(hass) - 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") - ) + with freeze_time(): + now = utcnow().isoformat() - await client.send_json_auto_id({"type": "conversation/chat_log/subscribe_index"}) - msg = await client.receive_json() - assert msg["success"] - event_id = msg["id"] + 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") + ) - # 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, - }, - ], - } - ], - }, - } + await client.send_json_auto_id( + {"type": "conversation/chat_log/subscribe_index"} + ) + msg = await client.receive_json() + assert msg["success"] + event_id = msg["id"] - with ( - chat_session.async_get_chat_session(hass) as session, - async_get_chat_log(hass, session, mock_conversation_input), - ): - conversation_id = session.conversation_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, + }, + ], + } + ], + }, + } - # 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": ""}], - } + 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": {}, - }, - } + # 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) + # 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": {}, - }, - } + # 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( @@ -738,7 +740,6 @@ async def test_ws_chat_log_index_subscription_requires_admin( assert msg["error"]["code"] == "unauthorized" -@freeze_time() async def test_ws_chat_log_subscription( hass: HomeAssistant, init_components, @@ -746,172 +747,174 @@ async def test_ws_chat_log_subscription( hass_ws_client: WebSocketGenerator, ) -> None: """Test that we can subscribe to chat logs.""" - now = utcnow().isoformat() client = await hass_ws_client(hass) - 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") - ) + with freeze_time(): + now = utcnow().isoformat() - 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"] + 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") + ) - # 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": { + await client.send_json_auto_id( + { + "type": "conversation/chat_log/subscribe", "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") + } ) + msg = await client.receive_json() + assert msg["success"] + event_id = msg["id"] - # 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, + # 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}, { - "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", + "content": "I hear you", "created": now, }, ], }, }, - }, - } + } - # Trigger session cleanup - with patch( - "homeassistant.helpers.chat_session.CONVERSATION_TIMEOUT", - timedelta(0), - ): - async_fire_time_changed(hass, fire_all=True) + 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") + ) - # 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": {}, - }, - } + # 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, + }, + }, + }, + } - # Subscribing now will fail - await client.send_json_auto_id( - { - "type": "conversation/chat_log/subscribe", - "conversation_id": conversation_id, + # 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, + }, + }, + }, } - ) - msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "not_found" + + # 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 c33738d409b9ee..4351aae2bcf80d 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 a46e684648a490..bbeaff0217a45e 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 ( @@ -240,6 +241,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, @@ -299,6 +301,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, From a6452482fa4d695dacbf20a47869631eaa47a2b1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Nov 2025 14:41:40 -0500 Subject: [PATCH 5/7] Fix tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/components/ai_task/snapshots/test_task.ambr | 3 +++ tests/components/assist_pipeline/snapshots/test_pipeline.ambr | 1 + tests/components/assist_pipeline/test_pipeline.py | 2 ++ 3 files changed, 6 insertions(+) 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/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, From a89c09a140f4ea72aad97d3811bb3878b4d57eb8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Nov 2025 15:56:57 -0500 Subject: [PATCH 6/7] Update homeassistant/components/conversation/http.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/conversation/http.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 7ad6b2d04e142b..349f03191c8da9 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -314,11 +314,11 @@ def forward_events(conversation_id: str, event_type: str, data: dict) -> None: if event_type == ChatLogEventType.DELETED: unsubscribe() - del connection.subscriptions[msg["id"]] + del connection.subscriptions[msg_id] unsubscribe = async_subscribe_chat_logs(hass, forward_events) - connection.subscriptions[msg["id"]] = unsubscribe - connection.send_result(msg["id"]) + connection.subscriptions[msg_id] = unsubscribe + connection.send_result(msg_id) with ( async_get_chat_session(hass, subscribed_conversation) as session, From e0ebc78f87e55529cc5a39b043a67f02ecc54204 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Nov 2025 16:03:10 -0500 Subject: [PATCH 7/7] Address comments --- homeassistant/components/conversation/chat_log.py | 6 +++++- tests/components/conversation/test_http.py | 7 ++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 9629bf7c4f94e8..4ee8a8cc310bf0 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -207,7 +207,11 @@ class SystemContent: def as_dict(self) -> dict[str, Any]: """Return a dictionary representation of the content.""" - return {"role": self.role, "content": self.content} + return { + "role": self.role, + "content": self.content, + "created": self.created, + } @dataclass(frozen=True) diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index ba858f6c28e42c..8f9e85a9d12f1d 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -646,7 +646,7 @@ async def test_ws_chat_log_index_subscription( "continue_conversation": False, "created": now, "content": [ - {"role": "system", "content": ""}, + {"role": "system", "content": "", "created": now}, {"role": "user", "content": "Hello", "created": now}, { "role": "assistant", @@ -680,7 +680,7 @@ async def test_ws_chat_log_index_subscription( "conversation_id": conversation_id, "continue_conversation": False, "created": now, - "content": [{"role": "system", "content": ""}], + "content": [{"role": "system", "content": "", "created": now}], } }, }, @@ -783,7 +783,7 @@ async def test_ws_chat_log_subscription( "continue_conversation": False, "created": now, "content": [ - {"role": "system", "content": ""}, + {"role": "system", "content": "", "created": now}, {"role": "user", "content": "Hello", "created": now}, { "role": "assistant", @@ -860,6 +860,7 @@ async def test_ws_chat_log_subscription( { "content": "", "role": "system", + "created": now, }, { "content": "Hello",