mirror of
https://github.com/home-assistant/core.git
synced 2025-11-05 00:49:37 +00:00
Compare commits
7 Commits
2025.11.0b
...
chat-log-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a88b082a1 | ||
|
|
3a36c060f6 | ||
|
|
55bec3ea61 | ||
|
|
2f453a09ff | ||
|
|
15f42a205a | ||
|
|
45c48f25d9 | ||
|
|
646f685a8f |
@@ -7,6 +7,7 @@ from collections.abc import AsyncGenerator, AsyncIterable, Callable, Generator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict, cast
|
||||
@@ -16,14 +17,18 @@ import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
from homeassistant.helpers import chat_session, frame, intent, llm, template
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
from . import trace
|
||||
from .const import ChatLogEventType
|
||||
from .models import ConversationInput, ConversationResult
|
||||
|
||||
DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs")
|
||||
|
||||
DATA_SUBSCRIPTIONS: HassKey[
|
||||
list[Callable[[str, ChatLogEventType, dict[str, Any]], None]]
|
||||
] = HassKey("conversation_chat_log_subscriptions")
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
current_chat_log: ContextVar[ChatLog | None] = ContextVar(
|
||||
@@ -31,6 +36,40 @@ current_chat_log: ContextVar[ChatLog | None] = ContextVar(
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_subscribe_chat_logs(
|
||||
hass: HomeAssistant,
|
||||
callback_func: Callable[[str, ChatLogEventType, dict[str, Any]], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to all chat logs."""
|
||||
subscriptions = hass.data.get(DATA_SUBSCRIPTIONS)
|
||||
if subscriptions is None:
|
||||
subscriptions = []
|
||||
hass.data[DATA_SUBSCRIPTIONS] = subscriptions
|
||||
|
||||
subscriptions.append(callback_func)
|
||||
|
||||
@callback
|
||||
def unsubscribe() -> None:
|
||||
"""Unsubscribe from chat logs."""
|
||||
subscriptions.remove(callback_func)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
|
||||
@callback
|
||||
def _async_notify_subscribers(
|
||||
hass: HomeAssistant,
|
||||
conversation_id: str,
|
||||
event_type: ChatLogEventType,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Notify subscribers of a chat log event."""
|
||||
if subscriptions := hass.data.get(DATA_SUBSCRIPTIONS):
|
||||
for callback_func in subscriptions:
|
||||
callback_func(conversation_id, event_type, data)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def async_get_chat_log(
|
||||
hass: HomeAssistant,
|
||||
@@ -63,6 +102,8 @@ def async_get_chat_log(
|
||||
all_chat_logs = {}
|
||||
hass.data[DATA_CHAT_LOGS] = all_chat_logs
|
||||
|
||||
is_new_log = session.conversation_id not in all_chat_logs
|
||||
|
||||
if chat_log := all_chat_logs.get(session.conversation_id):
|
||||
chat_log = replace(chat_log, content=chat_log.content.copy())
|
||||
else:
|
||||
@@ -71,6 +112,15 @@ def async_get_chat_log(
|
||||
if chat_log_delta_listener:
|
||||
chat_log.delta_listener = chat_log_delta_listener
|
||||
|
||||
# Fire CREATED event for new chat logs before any content is added
|
||||
if is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
session.conversation_id,
|
||||
ChatLogEventType.CREATED,
|
||||
{"chat_log": chat_log.as_dict()},
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
chat_log.async_add_user_content(UserContent(content=user_input.text))
|
||||
|
||||
@@ -84,14 +134,28 @@ def async_get_chat_log(
|
||||
LOGGER.debug(
|
||||
"Chat Log opened but no assistant message was added, ignoring update"
|
||||
)
|
||||
# If this was a new log but nothing was added, fire DELETED to clean up
|
||||
if is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
session.conversation_id,
|
||||
ChatLogEventType.DELETED,
|
||||
{},
|
||||
)
|
||||
return
|
||||
|
||||
if session.conversation_id not in all_chat_logs:
|
||||
if is_new_log:
|
||||
|
||||
@callback
|
||||
def do_cleanup() -> None:
|
||||
"""Handle cleanup."""
|
||||
all_chat_logs.pop(session.conversation_id)
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
session.conversation_id,
|
||||
ChatLogEventType.DELETED,
|
||||
{},
|
||||
)
|
||||
|
||||
session.async_on_cleanup(do_cleanup)
|
||||
|
||||
@@ -100,6 +164,16 @@ def async_get_chat_log(
|
||||
|
||||
all_chat_logs[session.conversation_id] = chat_log
|
||||
|
||||
# For new logs, CREATED was already fired before content was added
|
||||
# For existing logs, fire UPDATED
|
||||
if not is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
session.conversation_id,
|
||||
ChatLogEventType.UPDATED,
|
||||
{"chat_log": chat_log.as_dict()},
|
||||
)
|
||||
|
||||
|
||||
class ConverseError(HomeAssistantError):
|
||||
"""Error during initialization of conversation.
|
||||
@@ -129,6 +203,11 @@ class SystemContent:
|
||||
|
||||
role: Literal["system"] = field(init=False, default="system")
|
||||
content: str
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
return {"role": self.role, "content": self.content}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -138,6 +217,20 @@ class UserContent:
|
||||
role: Literal["user"] = field(init=False, default="user")
|
||||
content: str
|
||||
attachments: list[Attachment] | None = field(default=None)
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
result: dict[str, Any] = {
|
||||
"role": self.role,
|
||||
"content": self.content,
|
||||
"created": self.created,
|
||||
}
|
||||
if self.attachments:
|
||||
result["attachments"] = [
|
||||
attachment.as_dict() for attachment in self.attachments
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -153,6 +246,14 @@ class Attachment:
|
||||
path: Path
|
||||
"""Path to the attachment on disk."""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the attachment."""
|
||||
return {
|
||||
"media_content_id": self.media_content_id,
|
||||
"mime_type": self.mime_type,
|
||||
"path": str(self.path),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantContent:
|
||||
@@ -164,6 +265,22 @@ class AssistantContent:
|
||||
thinking_content: str | None = None
|
||||
tool_calls: list[llm.ToolInput] | None = None
|
||||
native: Any = None
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
result: dict[str, Any] = {
|
||||
"role": self.role,
|
||||
"agent_id": self.agent_id,
|
||||
"created": self.created,
|
||||
}
|
||||
if self.content:
|
||||
result["content"] = self.content
|
||||
if self.thinking_content:
|
||||
result["thinking_content"] = self.thinking_content
|
||||
if self.tool_calls:
|
||||
result["tool_calls"] = self.tool_calls
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -175,6 +292,18 @@ class ToolResultContent:
|
||||
tool_call_id: str
|
||||
tool_name: str
|
||||
tool_result: JsonObjectType
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
return {
|
||||
"role": self.role,
|
||||
"agent_id": self.agent_id,
|
||||
"tool_call_id": self.tool_call_id,
|
||||
"tool_name": self.tool_name,
|
||||
"tool_result": self.tool_result,
|
||||
"created": self.created,
|
||||
}
|
||||
|
||||
|
||||
type Content = SystemContent | UserContent | AssistantContent | ToolResultContent
|
||||
@@ -210,6 +339,16 @@ class ChatLog:
|
||||
llm_api: llm.APIInstance | None = None
|
||||
delta_listener: Callable[[ChatLog, dict], None] | None = None
|
||||
llm_input_provided_index = 0
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the chat log."""
|
||||
return {
|
||||
"conversation_id": self.conversation_id,
|
||||
"continue_conversation": self.continue_conversation,
|
||||
"content": [c.as_dict() for c in self.content],
|
||||
"created": self.created,
|
||||
}
|
||||
|
||||
@property
|
||||
def continue_conversation(self) -> bool:
|
||||
@@ -241,6 +380,12 @@ class ChatLog:
|
||||
"""Add user content to the log."""
|
||||
LOGGER.debug("Adding user content: %s", content)
|
||||
self.content.append(content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
self.conversation_id,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{"content": content.as_dict()},
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_add_assistant_content_without_tools(
|
||||
@@ -259,6 +404,12 @@ class ChatLog:
|
||||
):
|
||||
raise ValueError("Non-external tool calls not allowed")
|
||||
self.content.append(content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
self.conversation_id,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{"content": content.as_dict()},
|
||||
)
|
||||
|
||||
async def async_add_assistant_content(
|
||||
self,
|
||||
@@ -317,6 +468,14 @@ class ChatLog:
|
||||
tool_result=tool_result,
|
||||
)
|
||||
self.content.append(response_content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
self.conversation_id,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{
|
||||
"content": response_content.as_dict(),
|
||||
},
|
||||
)
|
||||
yield response_content
|
||||
|
||||
async def async_add_delta_content_stream(
|
||||
@@ -593,6 +752,12 @@ class ChatLog:
|
||||
self.llm_api = llm_api
|
||||
self.extra_system_prompt = extra_system_prompt
|
||||
self.content[0] = SystemContent(content=prompt)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
self.conversation_id,
|
||||
ChatLogEventType.UPDATED,
|
||||
{"chat_log": self.as_dict()},
|
||||
)
|
||||
|
||||
LOGGER.debug("Prompt: %s", self.content)
|
||||
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.components import http, websocket_api
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.chat_session import async_get_chat_session
|
||||
from homeassistant.util import language as language_util
|
||||
|
||||
from .agent_manager import (
|
||||
@@ -20,7 +21,8 @@ from .agent_manager import (
|
||||
async_get_agent,
|
||||
get_agent_manager,
|
||||
)
|
||||
from .const import DATA_COMPONENT
|
||||
from .chat_log import DATA_CHAT_LOGS, async_get_chat_log, async_subscribe_chat_logs
|
||||
from .const import DATA_COMPONENT, ChatLogEventType
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput
|
||||
|
||||
@@ -35,6 +37,8 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_list_sentences)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_debug)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_language_scores)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_chat_log)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_chat_log_index)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -265,3 +269,114 @@ class ConversationProcessView(http.HomeAssistantView):
|
||||
)
|
||||
|
||||
return self.json(result.as_dict())
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "conversation/chat_log/subscribe",
|
||||
vol.Required("conversation_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
def websocket_subscribe_chat_log(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to a chat log."""
|
||||
msg_id = msg["id"]
|
||||
subscribed_conversation = msg["conversation_id"]
|
||||
|
||||
chat_logs = hass.data.get(DATA_CHAT_LOGS)
|
||||
|
||||
if not chat_logs or subscribed_conversation not in chat_logs:
|
||||
connection.send_error(
|
||||
msg_id,
|
||||
websocket_api.ERR_NOT_FOUND,
|
||||
"Conversation chat log not found",
|
||||
)
|
||||
return
|
||||
|
||||
@callback
|
||||
def forward_events(conversation_id: str, event_type: str, data: dict) -> None:
|
||||
"""Forward chat log events to websocket connection."""
|
||||
if conversation_id != subscribed_conversation:
|
||||
return
|
||||
|
||||
connection.send_event(
|
||||
msg_id,
|
||||
{
|
||||
"conversation_id": conversation_id,
|
||||
"event_type": event_type,
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
|
||||
if event_type == ChatLogEventType.DELETED:
|
||||
unsubscribe()
|
||||
del connection.subscriptions[msg["id"]]
|
||||
|
||||
unsubscribe = async_subscribe_chat_logs(hass, forward_events)
|
||||
connection.subscriptions[msg["id"]] = unsubscribe
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
with (
|
||||
async_get_chat_session(hass, subscribed_conversation) as session,
|
||||
async_get_chat_log(hass, session) as chat_log,
|
||||
):
|
||||
connection.send_event(
|
||||
msg_id,
|
||||
{
|
||||
"event_type": ChatLogEventType.INITIAL_STATE,
|
||||
"data": chat_log.as_dict(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "conversation/chat_log/subscribe_index",
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
def websocket_subscribe_chat_log_index(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to a chat log."""
|
||||
msg_id = msg["id"]
|
||||
|
||||
@callback
|
||||
def forward_events(
|
||||
conversation_id: str, event_type: ChatLogEventType, data: dict
|
||||
) -> None:
|
||||
"""Forward chat log events to websocket connection."""
|
||||
if event_type not in (ChatLogEventType.CREATED, ChatLogEventType.DELETED):
|
||||
return
|
||||
|
||||
connection.send_event(
|
||||
msg_id,
|
||||
{
|
||||
"conversation_id": conversation_id,
|
||||
"event_type": event_type,
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
|
||||
unsubscribe = async_subscribe_chat_logs(hass, forward_events)
|
||||
connection.subscriptions[msg["id"]] = unsubscribe
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
chat_logs = hass.data.get(DATA_CHAT_LOGS)
|
||||
|
||||
if not chat_logs:
|
||||
return
|
||||
|
||||
connection.send_event(
|
||||
msg_id,
|
||||
{
|
||||
"event_type": ChatLogEventType.INITIAL_STATE,
|
||||
"data": [c.as_dict() for c in chat_logs.values()],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
|
||||
DOMAIN = ha.DOMAIN
|
||||
|
||||
DATA_EXPOSED_ENTITIES: HassKey[ExposedEntities] = HassKey(f"{DOMAIN}.exposed_entites")
|
||||
DATA_EXPOSED_ENTITIES: HassKey[ExposedEntities] = HassKey(f"{DOMAIN}.exposed_entities")
|
||||
DATA_STOP_HANDLER = f"{DOMAIN}.stop_handler"
|
||||
|
||||
SERVICE_HOMEASSISTANT_STOP: Final = "stop"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -785,6 +785,7 @@ async def test_extended_thinking(
|
||||
assert chat_log.content[2].content == "Hello, how can I help you today?"
|
||||
|
||||
|
||||
@freeze_time("2024-05-24 12:00:00")
|
||||
async def test_redacted_thinking(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_with_extended_thinking: MockConfigEntry,
|
||||
@@ -911,6 +912,7 @@ async def test_extended_thinking_tool_call(
|
||||
assert mock_create.mock_calls[1][2]["messages"] == snapshot
|
||||
|
||||
|
||||
@freeze_time("2025-10-31 12:00:00")
|
||||
async def test_web_search(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_with_web_search: MockConfigEntry,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Generator
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, AsyncMock, Mock, patch
|
||||
|
||||
from freezegun import freeze_time
|
||||
from hassil.recognize import Intent, IntentData, RecognizeResult
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
@@ -1637,6 +1638,7 @@ async def test_pipeline_language_used_instead_of_conversation_language(
|
||||
),
|
||||
],
|
||||
)
|
||||
@freeze_time("2025-10-31 12:00:00")
|
||||
async def test_chat_log_tts_streaming(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
from dataclasses import asdict
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from freezegun import freeze_time
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
import voluptuous as vol
|
||||
@@ -16,7 +19,12 @@ from homeassistant.components.conversation import (
|
||||
UserContent,
|
||||
async_get_chat_log,
|
||||
)
|
||||
from homeassistant.components.conversation.chat_log import DATA_CHAT_LOGS
|
||||
from homeassistant.components.conversation.chat_log import (
|
||||
DATA_CHAT_LOGS,
|
||||
Attachment,
|
||||
ChatLogEventType,
|
||||
async_subscribe_chat_logs,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import chat_session, llm
|
||||
@@ -398,6 +406,7 @@ async def test_extra_systen_prompt(
|
||||
assert chat_log.content[0].content.endswith(extra_system_prompt2)
|
||||
|
||||
|
||||
@freeze_time("2025-10-31 18:00:00")
|
||||
@pytest.mark.parametrize(
|
||||
"prerun_tool_tasks",
|
||||
[
|
||||
@@ -484,6 +493,7 @@ async def test_tool_call(
|
||||
)
|
||||
|
||||
|
||||
@freeze_time("2025-10-31 12:00:00")
|
||||
async def test_tool_call_exception(
|
||||
hass: HomeAssistant,
|
||||
mock_conversation_input: ConversationInput,
|
||||
@@ -536,6 +546,7 @@ async def test_tool_call_exception(
|
||||
)
|
||||
|
||||
|
||||
@freeze_time("2025-10-31 12:00:00")
|
||||
@pytest.mark.parametrize(
|
||||
"deltas",
|
||||
[
|
||||
@@ -841,3 +852,171 @@ async def test_chat_log_continue_conversation(
|
||||
)
|
||||
)
|
||||
assert chat_log.continue_conversation is True
|
||||
|
||||
|
||||
@freeze_time("2025-10-31 12:00:00")
|
||||
async def test_chat_log_subscription(
|
||||
hass: HomeAssistant,
|
||||
mock_conversation_input: ConversationInput,
|
||||
) -> None:
|
||||
"""Test comprehensive chat log subscription functionality."""
|
||||
|
||||
# Track all events received
|
||||
received_events = []
|
||||
|
||||
def event_callback(
|
||||
conversation_id: str, event_type: ChatLogEventType, data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Track received events."""
|
||||
received_events.append((conversation_id, event_type, data))
|
||||
|
||||
# Subscribe to chat log events
|
||||
unsubscribe = async_subscribe_chat_logs(hass, event_callback)
|
||||
|
||||
with (
|
||||
chat_session.async_get_chat_session(hass) as session,
|
||||
async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
|
||||
):
|
||||
conversation_id = session.conversation_id
|
||||
|
||||
# Test adding different types of content and verify events are sent
|
||||
chat_log.async_add_user_content(
|
||||
UserContent(
|
||||
content="Check this image",
|
||||
attachments=[
|
||||
Attachment(
|
||||
mime_type="image/jpeg",
|
||||
media_content_id="media-source://bla",
|
||||
path=Path("test_image.jpg"),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
# Check user content with attachments event
|
||||
assert received_events[-1][1] == ChatLogEventType.CONTENT_ADDED
|
||||
user_event = received_events[-1][2]["content"]
|
||||
assert user_event["content"] == "Check this image"
|
||||
assert len(user_event["attachments"]) == 1
|
||||
assert user_event["attachments"][0]["mime_type"] == "image/jpeg"
|
||||
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
AssistantContent(
|
||||
agent_id="test-agent", content="Hello! How can I help you?"
|
||||
)
|
||||
)
|
||||
# Check basic assistant content event
|
||||
assert received_events[-1][1] == ChatLogEventType.CONTENT_ADDED
|
||||
basic_event = received_events[-1][2]["content"]
|
||||
assert basic_event["content"] == "Hello! How can I help you?"
|
||||
assert basic_event["agent_id"] == "test-agent"
|
||||
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
AssistantContent(
|
||||
agent_id="test-agent",
|
||||
content="Let me think about that...",
|
||||
thinking_content="I need to analyze the user's request carefully.",
|
||||
)
|
||||
)
|
||||
# Check assistant content with thinking event
|
||||
assert received_events[-1][1] == ChatLogEventType.CONTENT_ADDED
|
||||
thinking_event = received_events[-1][2]["content"]
|
||||
assert (
|
||||
thinking_event["thinking_content"]
|
||||
== "I need to analyze the user's request carefully."
|
||||
)
|
||||
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
AssistantContent(
|
||||
agent_id="test-agent",
|
||||
content="Here's some data:",
|
||||
native={"type": "chart", "data": [1, 2, 3, 4, 5]},
|
||||
)
|
||||
)
|
||||
# Check assistant content with native event
|
||||
assert received_events[-1][1] == ChatLogEventType.CONTENT_ADDED
|
||||
native_event = received_events[-1][2]["content"]
|
||||
assert native_event["content"] == "Here's some data:"
|
||||
assert native_event["agent_id"] == "test-agent"
|
||||
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
ToolResultContent(
|
||||
agent_id="test-agent",
|
||||
tool_call_id="test-tool-call-123",
|
||||
tool_name="test_tool",
|
||||
tool_result="Tool execution completed successfully",
|
||||
)
|
||||
)
|
||||
# Check tool result content event
|
||||
assert received_events[-1][1] == ChatLogEventType.CONTENT_ADDED
|
||||
tool_result_event = received_events[-1][2]["content"]
|
||||
assert tool_result_event["tool_name"] == "test_tool"
|
||||
assert (
|
||||
tool_result_event["tool_result"] == "Tool execution completed successfully"
|
||||
)
|
||||
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
AssistantContent(
|
||||
agent_id="test-agent",
|
||||
content="I'll call an external service",
|
||||
tool_calls=[
|
||||
llm.ToolInput(
|
||||
id="external-tool-call-123",
|
||||
tool_name="external_api_call",
|
||||
tool_args={"endpoint": "https://api.example.com/data"},
|
||||
external=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
# Check external tool call event
|
||||
assert received_events[-1][1] == ChatLogEventType.CONTENT_ADDED
|
||||
external_tool_event = received_events[-1][2]["content"]
|
||||
assert len(external_tool_event["tool_calls"]) == 1
|
||||
assert external_tool_event["tool_calls"][0].tool_name == "external_api_call"
|
||||
|
||||
# Verify we received the expected events
|
||||
# Should have: 1 CREATED event + 7 CONTENT_ADDED events
|
||||
assert len(received_events) == 8
|
||||
|
||||
# Check the first event is CREATED
|
||||
assert received_events[0][1] == ChatLogEventType.CREATED
|
||||
assert received_events[0][2]["chat_log"]["conversation_id"] == conversation_id
|
||||
|
||||
# Check the second event is CONTENT_ADDED (from mock_conversation_input)
|
||||
assert received_events[1][1] == ChatLogEventType.CONTENT_ADDED
|
||||
assert received_events[1][0] == conversation_id
|
||||
|
||||
# Test cleanup functionality
|
||||
assert conversation_id in hass.data[chat_session.DATA_CHAT_SESSION]
|
||||
|
||||
# Set the last updated to be older than the timeout
|
||||
hass.data[chat_session.DATA_CHAT_SESSION][conversation_id].last_updated = (
|
||||
dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT
|
||||
)
|
||||
|
||||
async_fire_time_changed(
|
||||
hass,
|
||||
dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT * 2 + timedelta(seconds=1),
|
||||
)
|
||||
|
||||
# Check that DELETED event was sent
|
||||
assert received_events[-1][1] == ChatLogEventType.DELETED
|
||||
assert received_events[-1][0] == conversation_id
|
||||
|
||||
# Test that unsubscribing stops receiving events
|
||||
events_before_unsubscribe = len(received_events)
|
||||
unsubscribe()
|
||||
|
||||
# Create a new session and add content - should not receive events
|
||||
with (
|
||||
chat_session.async_get_chat_session(hass) as session2,
|
||||
async_get_chat_log(hass, session2, mock_conversation_input) as chat_log2,
|
||||
):
|
||||
chat_log2.async_add_assistant_content_without_tools(
|
||||
AssistantContent(
|
||||
agent_id="test-agent", content="This should not be received"
|
||||
)
|
||||
)
|
||||
|
||||
# Verify no new events were received after unsubscribing
|
||||
assert len(received_events) == events_before_unsubscribe
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user