From a859fc7187526b6153784fc886027b1a09cbace5 Mon Sep 17 00:00:00 2001 From: Lukasz Czarnecki <6866304+lukcz@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:28:06 +0200 Subject: [PATCH 1/6] Add native Assist streaming for OpenClaw conversation --- custom_components/openclaw/conversation.py | 231 +++++++++-- tests/test_conversation_streaming.py | 422 +++++++++++++++++++++ 2 files changed, 611 insertions(+), 42 deletions(-) create mode 100644 tests/test_conversation_streaming.py diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 633a7d1..fc05f2d 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -9,13 +9,13 @@ from datetime import datetime, timezone import logging import re -from typing import Any +from typing import Any, AsyncIterator from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import chat_session, intent from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers import intent from .api import OpenClawApiClient, OpenClawApiError from .const import ( @@ -57,8 +57,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the OpenClaw conversation agent.""" - agent = OpenClawConversationAgent(hass, entry) - conversation.async_set_agent(hass, entry, agent) + async_add_entities([OpenClawConversationAgent(hass, entry)]) async def async_unload_entry( @@ -66,21 +65,37 @@ async def async_unload_entry( entry: ConfigEntry, ) -> bool: """Unload the conversation agent.""" - conversation.async_unset_agent(hass, entry) return True -class OpenClawConversationAgent(conversation.AbstractConversationAgent): +class OpenClawConversationAgent( + conversation.ConversationEntity, + conversation.AbstractConversationAgent, +): """Conversation agent that routes messages through OpenClaw. Enables OpenClaw to appear as a selectable agent in the Assist pipeline, allowing use with Voice PE, satellites, and the built-in HA Assist dialog. """ + _attr_supports_streaming = True + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the conversation agent.""" self.hass = hass self.entry = entry + self._attr_unique_id = entry.entry_id + self._attr_name = entry.title or "OpenClaw" + + async def async_added_to_hass(self) -> None: + """Register the entity-backed agent when added to Home Assistant.""" + await super().async_added_to_hass() + conversation.async_set_agent(self.hass, self.entry, self) + + async def async_will_remove_from_hass(self) -> None: + """Unregister the entity-backed agent when removed from Home Assistant.""" + conversation.async_unset_agent(self.hass, self.entry) + await super().async_will_remove_from_hass() @property def attribution(self) -> dict[str, str]: @@ -132,7 +147,6 @@ async def async_process( ) ) resolved_agent_id = voice_agent_id or configured_agent_id - conversation_id = self._resolve_conversation_id(user_input, resolved_agent_id) active_model = self._normalize_optional_text(options.get("active_model")) include_context = options.get( CONF_INCLUDE_EXPOSED_CONTEXT, @@ -154,15 +168,20 @@ async def async_process( system_prompt = "\n\n".join( part for part in (exposed_context, extra_system_prompt) if part ) or None + backend_conversation_id = self._resolve_conversation_id( + user_input, + resolved_agent_id, + ) try: - full_response = await self._get_response( - client, - message, - conversation_id, - resolved_agent_id, - system_prompt, - active_model, + full_response, result = await self._async_process_with_chat_log( + user_input=user_input, + client=client, + backend_conversation_id=backend_conversation_id, + message=message, + agent_id=resolved_agent_id, + system_prompt=system_prompt, + model=active_model, ) except OpenClawApiError as err: _LOGGER.error("OpenClaw conversation error: %s", err) @@ -173,13 +192,14 @@ async def async_process( refreshed = await refresh_fn() if refreshed: try: - full_response = await self._get_response( - client, - message, - conversation_id, - resolved_agent_id, - system_prompt, - active_model, + full_response, result = await self._async_process_with_chat_log( + user_input=user_input, + client=client, + backend_conversation_id=backend_conversation_id, + message=message, + agent_id=resolved_agent_id, + system_prompt=system_prompt, + model=active_model, ) except OpenClawApiError as retry_err: return self._error_result( @@ -202,21 +222,15 @@ async def async_process( EVENT_MESSAGE_RECEIVED, { ATTR_MESSAGE: full_response, - ATTR_SESSION_ID: conversation_id, - ATTR_MODEL: coordinator.data.get(DATA_MODEL) if coordinator.data else None, + ATTR_SESSION_ID: backend_conversation_id, + ATTR_MODEL: ( + coordinator.data.get(DATA_MODEL) if coordinator.data else None + ), ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(), }, ) coordinator.update_last_activity() - - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(full_response) - - return conversation.ConversationResult( - response=intent_response, - conversation_id=conversation_id, - continue_conversation=self._should_continue(full_response), - ) + return result def _resolve_conversation_id( self, @@ -269,30 +283,163 @@ async def _get_response( system_prompt: str | None = None, model: str | None = None, ) -> str: - """Get a response from OpenClaw, trying streaming first.""" - full_response = "" - async for chunk in client.async_stream_message( + """Get a non-streaming response from OpenClaw.""" + response = await client.async_send_message( message=message, session_id=conversation_id, model=model, system_prompt=system_prompt, agent_id=agent_id, extra_headers=_VOICE_REQUEST_HEADERS, + ) + return extract_text_recursive(response) or "" + + async def _async_process_with_chat_log( + self, + user_input: conversation.ConversationInput, + client: OpenClawApiClient, + backend_conversation_id: str, + message: str, + agent_id: str | None = None, + system_prompt: str | None = None, + model: str | None = None, + ) -> tuple[str, conversation.ConversationResult]: + """Write the assistant response into the Home Assistant chat log.""" + chat_log_conversation_id = user_input.conversation_id or backend_conversation_id + + with ( + chat_session.async_get_chat_session( + self.hass, + chat_log_conversation_id, + ) as session, + conversation.async_get_chat_log( + self.hass, + session, + user_input, + ) as chat_log, + ): + full_response = await self._async_populate_chat_log( + chat_log=chat_log, + client=client, + message=message, + conversation_id=backend_conversation_id, + agent_id=agent_id, + system_prompt=system_prompt, + model=model, + ) + result = conversation.async_get_result_from_chat_log(user_input, chat_log) + result.continue_conversation = ( + self._should_continue(full_response) or result.continue_conversation + ) + return full_response, result + + async def _async_populate_chat_log( + self, + chat_log: conversation.ChatLog, + client: OpenClawApiClient, + message: str, + conversation_id: str, + agent_id: str | None = None, + system_prompt: str | None = None, + model: str | None = None, + ) -> str: + """Populate the HA chat log from streaming or fallback responses.""" + try: + full_response = await self._async_stream_response_to_chat_log( + chat_log=chat_log, + client=client, + message=message, + conversation_id=conversation_id, + agent_id=agent_id, + system_prompt=system_prompt, + model=model, + ) + if full_response: + return full_response + except OpenClawApiError as err: + _LOGGER.warning( + "OpenClaw streaming failed, falling back to non-streaming response: %s", + err, + ) + + full_response = await self._get_response( + client=client, + message=message, + conversation_id=conversation_id, + agent_id=agent_id, + system_prompt=system_prompt, + model=model, + ) + self._add_final_response_to_chat_log(chat_log, full_response) + return full_response + + async def _async_stream_response_to_chat_log( + self, + chat_log: conversation.ChatLog, + client: OpenClawApiClient, + message: str, + conversation_id: str, + agent_id: str | None = None, + system_prompt: str | None = None, + model: str | None = None, + ) -> str: + """Stream OpenClaw deltas into the HA chat log.""" + full_response_parts: list[str] = [] + async for content in chat_log.async_add_delta_content_stream( + self._chat_log_agent_id, + self._async_openclaw_delta_stream( + client=client, + message=message, + conversation_id=conversation_id, + agent_id=agent_id, + system_prompt=system_prompt, + model=model, + ), ): - full_response += chunk + if isinstance(content, conversation.AssistantContent) and content.content: + full_response_parts.append(content.content) + return "".join(full_response_parts) - if full_response: - return full_response + async def _async_openclaw_delta_stream( + self, + client: OpenClawApiClient, + message: str, + conversation_id: str, + agent_id: str | None = None, + system_prompt: str | None = None, + model: str | None = None, + ) -> AsyncIterator[dict[str, Any]]: + """Map OpenClaw SSE chunks to the delta format expected by HA.""" + yield {"role": "assistant"} - response = await client.async_send_message( + async for chunk in client.async_stream_message( message=message, session_id=conversation_id, model=model, system_prompt=system_prompt, agent_id=agent_id, extra_headers=_VOICE_REQUEST_HEADERS, + ): + if chunk: + yield {"content": chunk} + + @property + def _chat_log_agent_id(self) -> str: + """Return the assistant identifier used for HA chat log messages.""" + return self.entity_id or self.entry.entry_id + + def _add_final_response_to_chat_log( + self, + chat_log: conversation.ChatLog, + full_response: str, + ) -> None: + """Append a non-streaming final assistant message to the chat log.""" + chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id=self._chat_log_agent_id, + content=full_response or None, + ) ) - return extract_text_recursive(response) or "" @staticmethod def _should_continue(response: str) -> bool: @@ -312,8 +459,8 @@ def _should_continue(response: str) -> bool: text = response.strip() # Check if the response ends with a question mark - # (allow trailing punctuation like quotes, parens, or emoji) - if re.search(r"\?\s*[\"'""»)\]]*\s*$", text): + # (allow trailing punctuation like quotes or parens) + if re.search(r"\?\s*['\")\]]*\s*$", text): return True # Common follow-up patterns (EN + DE) diff --git a/tests/test_conversation_streaming.py b/tests/test_conversation_streaming.py new file mode 100644 index 0000000..4f62ce8 --- /dev/null +++ b/tests/test_conversation_streaming.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +import asyncio +from contextlib import contextmanager +from dataclasses import dataclass +import importlib.util +from pathlib import Path +import sys +import types +from typing import Any + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +MODULE_NAME = "custom_components.openclaw.conversation" +MODULE_PATH = REPO_ROOT / "custom_components" / "openclaw" / "conversation.py" + + +class FakeBus: + def __init__(self) -> None: + self.events: list[tuple[str, dict[str, Any]]] = [] + + def async_fire(self, event_type: str, event_data: dict[str, Any]) -> None: + self.events.append((event_type, event_data)) + + +class FakeHass: + def __init__(self) -> None: + self.data: dict[str, Any] = {} + self.bus = FakeBus() + self._last_chat_log: FakeChatLog | None = None + + +@dataclass +class FakeConfigEntry: + entry_id: str + title: str + data: dict[str, Any] + options: dict[str, Any] + + +@dataclass +class FakeContext: + user_id: str | None = None + + +@dataclass +class FakeConversationInput: + text: str + context: FakeContext + conversation_id: str | None + device_id: str | None + satellite_id: str | None + language: str + agent_id: str + extra_system_prompt: str | None = None + + +@dataclass +class FakeConversationResult: + response: Any + conversation_id: str | None = None + continue_conversation: bool = False + + +@dataclass +class FakeAssistantContent: + agent_id: str + content: str | None = None + + +class FakeIntentResponse: + def __init__(self, language: str) -> None: + self.language = language + self.speech: str = "" + self.error: tuple[str, str] | None = None + + def async_set_speech(self, speech: str) -> None: + self.speech = speech + + def async_set_error(self, code: str, message: str) -> None: + self.error = (code, message) + + +class FakeConversationEntity: + _attr_supports_streaming = False + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.entity_id: str | None = None + + @property + def supports_streaming(self) -> bool: + return self._attr_supports_streaming + + async def async_added_to_hass(self) -> None: + return None + + async def async_will_remove_from_hass(self) -> None: + return None + + +class FakeAbstractConversationAgent: + pass + + +class FakeChatLog: + def __init__(self, conversation_id: str) -> None: + self.conversation_id = conversation_id + self.deltas: list[dict[str, Any]] = [] + self.added: list[FakeAssistantContent] = [] + self.continue_conversation = False + + async def async_add_delta_content_stream( + self, + agent_id: str, + stream, + ): + full_response = "" + async for delta in stream: + self.deltas.append(delta) + if content := delta.get("content"): + full_response += content + + if full_response: + assistant_content = FakeAssistantContent( + agent_id=agent_id, + content=full_response, + ) + self.added.append(assistant_content) + yield assistant_content + + def async_add_assistant_content_without_tools( + self, + content: FakeAssistantContent, + ) -> None: + self.added.append(content) + + +class FakeCoordinator: + def __init__(self, data: dict[str, Any] | None = None) -> None: + self.data = data or {} + self.updated = False + + def update_last_activity(self) -> None: + self.updated = True + + +class FakeClient: + def __init__( + self, + *, + stream_chunks: list[str] | None = None, + response: dict[str, Any] | None = None, + stream_error: Exception | None = None, + ) -> None: + self.stream_chunks = stream_chunks or [] + self.response = response or {"text": ""} + self.stream_error = stream_error + self.stream_calls: list[dict[str, Any]] = [] + self.send_calls: list[dict[str, Any]] = [] + + async def async_stream_message(self, **kwargs: Any): + self.stream_calls.append(kwargs) + if self.stream_error is not None: + raise self.stream_error + + for chunk in self.stream_chunks: + yield chunk + + async def async_send_message(self, **kwargs: Any) -> dict[str, Any]: + self.send_calls.append(kwargs) + if isinstance(self.response, Exception): + raise self.response + return self.response + + +def _extract_text_recursive(value: Any) -> str | None: + if isinstance(value, str): + return value + if isinstance(value, dict): + for key in ("output_text", "text", "content", "message", "response", "answer"): + nested = value.get(key) + if nested: + return _extract_text_recursive(nested) + return None + + +def _install_stub_modules() -> None: + for name in list(sys.modules): + if name == "homeassistant" or name.startswith("homeassistant."): + sys.modules.pop(name) + if name == "custom_components" or name.startswith("custom_components.openclaw"): + sys.modules.pop(name) + + homeassistant = types.ModuleType("homeassistant") + components = types.ModuleType("homeassistant.components") + helpers = types.ModuleType("homeassistant.helpers") + + conversation_module = types.ModuleType("homeassistant.components.conversation") + conversation_module.MATCH_ALL = "*" + conversation_module.ConversationEntity = FakeConversationEntity + conversation_module.AbstractConversationAgent = FakeAbstractConversationAgent + conversation_module.ConversationInput = FakeConversationInput + conversation_module.ConversationResult = FakeConversationResult + conversation_module.ChatLog = FakeChatLog + conversation_module.AssistantContent = FakeAssistantContent + conversation_module.async_set_agent = lambda hass, entry, agent: None + conversation_module.async_unset_agent = lambda hass, entry: None + + @contextmanager + def async_get_chat_log(hass: FakeHass, session, user_input): + chat_log = FakeChatLog(session.conversation_id) + hass._last_chat_log = chat_log + yield chat_log + + def async_get_result_from_chat_log(user_input: FakeConversationInput, chat_log: FakeChatLog): + response = FakeIntentResponse(user_input.language) + last_content = chat_log.added[-1].content if chat_log.added else "" + response.async_set_speech(last_content or "") + return FakeConversationResult( + response=response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) + + conversation_module.async_get_chat_log = async_get_chat_log + conversation_module.async_get_result_from_chat_log = async_get_result_from_chat_log + + chat_session_module = types.ModuleType("homeassistant.helpers.chat_session") + + @contextmanager + def async_get_chat_session(hass: FakeHass, conversation_id: str | None): + yield types.SimpleNamespace( + conversation_id=conversation_id or "generated-conversation-id" + ) + + chat_session_module.async_get_chat_session = async_get_chat_session + + intent_module = types.ModuleType("homeassistant.helpers.intent") + intent_module.IntentResponse = FakeIntentResponse + intent_module.IntentResponseErrorCode = types.SimpleNamespace(UNKNOWN="unknown") + + entity_platform_module = types.ModuleType("homeassistant.helpers.entity_platform") + entity_platform_module.AddEntitiesCallback = object + + config_entries_module = types.ModuleType("homeassistant.config_entries") + config_entries_module.ConfigEntry = FakeConfigEntry + + core_module = types.ModuleType("homeassistant.core") + core_module.HomeAssistant = FakeHass + + custom_components = types.ModuleType("custom_components") + custom_components.__path__ = [str(REPO_ROOT / "custom_components")] + + openclaw_package = types.ModuleType("custom_components.openclaw") + openclaw_package.__path__ = [str(REPO_ROOT / "custom_components" / "openclaw")] + + api_module = types.ModuleType("custom_components.openclaw.api") + + class OpenClawApiError(Exception): + pass + + class OpenClawApiClient: + pass + + api_module.OpenClawApiError = OpenClawApiError + api_module.OpenClawApiClient = OpenClawApiClient + + const_module = types.ModuleType("custom_components.openclaw.const") + const_values = { + "ATTR_MESSAGE": "message", + "ATTR_MODEL": "model", + "ATTR_SESSION_ID": "session_id", + "ATTR_TIMESTAMP": "timestamp", + "CONF_ASSIST_SESSION_ID": "assist_session_id", + "CONF_AGENT_ID": "agent_id", + "CONF_CONTEXT_MAX_CHARS": "context_max_chars", + "CONF_CONTEXT_STRATEGY": "context_strategy", + "CONF_INCLUDE_EXPOSED_CONTEXT": "include_exposed_context", + "CONF_VOICE_AGENT_ID": "voice_agent_id", + "DEFAULT_ASSIST_SESSION_ID": "", + "DEFAULT_AGENT_ID": "main", + "DEFAULT_CONTEXT_MAX_CHARS": 13000, + "DEFAULT_CONTEXT_STRATEGY": "truncate", + "DEFAULT_INCLUDE_EXPOSED_CONTEXT": True, + "DATA_MODEL": "model", + "DOMAIN": "openclaw", + "EVENT_MESSAGE_RECEIVED": "openclaw_message_received", + } + for key, value in const_values.items(): + setattr(const_module, key, value) + + coordinator_module = types.ModuleType("custom_components.openclaw.coordinator") + coordinator_module.OpenClawCoordinator = FakeCoordinator + + exposure_module = types.ModuleType("custom_components.openclaw.exposure") + exposure_module.apply_context_policy = lambda raw, max_chars, strategy: raw + exposure_module.build_exposed_entities_context = lambda hass, assistant: None + + helpers_module = types.ModuleType("custom_components.openclaw.helpers") + helpers_module.extract_text_recursive = _extract_text_recursive + + sys.modules["homeassistant"] = homeassistant + sys.modules["homeassistant.components"] = components + sys.modules["homeassistant.components.conversation"] = conversation_module + sys.modules["homeassistant.helpers"] = helpers + sys.modules["homeassistant.helpers.chat_session"] = chat_session_module + sys.modules["homeassistant.helpers.intent"] = intent_module + sys.modules["homeassistant.helpers.entity_platform"] = entity_platform_module + sys.modules["homeassistant.config_entries"] = config_entries_module + sys.modules["homeassistant.core"] = core_module + sys.modules["custom_components"] = custom_components + sys.modules["custom_components.openclaw"] = openclaw_package + sys.modules["custom_components.openclaw.api"] = api_module + sys.modules["custom_components.openclaw.const"] = const_module + sys.modules["custom_components.openclaw.coordinator"] = coordinator_module + sys.modules["custom_components.openclaw.exposure"] = exposure_module + sys.modules["custom_components.openclaw.helpers"] = helpers_module + + +def _load_conversation_module(): + _install_stub_modules() + spec = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules[MODULE_NAME] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture() +def conversation_module(): + return _load_conversation_module() + + +def _make_agent(conversation_module, *, client: FakeClient, options: dict[str, Any] | None = None): + hass = FakeHass() + entry = FakeConfigEntry( + entry_id="entry-1", + title="OpenClaw", + data={"agent_id": "main"}, + options=options or {}, + ) + coordinator = FakeCoordinator({"model": "model-x"}) + hass.data[conversation_module.DOMAIN] = { + entry.entry_id: { + "client": client, + "coordinator": coordinator, + } + } + agent = conversation_module.OpenClawConversationAgent(hass, entry) + agent.entity_id = "conversation.openclaw" + return agent, hass, coordinator + + +def _make_user_input(*, text: str, conversation_id: str | None = "conv-1"): + return FakeConversationInput( + text=text, + context=FakeContext(user_id="user-123"), + conversation_id=conversation_id, + device_id=None, + satellite_id=None, + language="en", + agent_id="openclaw", + ) + + +def test_async_process_streams_into_chat_log(conversation_module) -> None: + client = FakeClient(stream_chunks=["Ala ", "ma ", "kota"]) + agent, hass, coordinator = _make_agent(conversation_module, client=client) + + result = asyncio.run(agent.async_process(_make_user_input(text="Hello there"))) + + assert result.response.speech == "Ala ma kota" + assert result.conversation_id == "conv-1" + assert coordinator.updated is True + assert client.send_calls == [] + assert client.stream_calls[0]["session_id"] == "conv-1:main" + assert hass._last_chat_log is not None + assert hass._last_chat_log.deltas == [ + {"role": "assistant"}, + {"content": "Ala "}, + {"content": "ma "}, + {"content": "kota"}, + ] + assert hass.bus.events[0][1]["session_id"] == "conv-1:main" + + +def test_async_process_falls_back_when_stream_is_empty(conversation_module) -> None: + client = FakeClient(stream_chunks=[], response={"text": "Fallback reply"}) + agent, hass, _ = _make_agent(conversation_module, client=client) + + result = asyncio.run(agent.async_process(_make_user_input(text="Hello there"))) + + assert result.response.speech == "Fallback reply" + assert len(client.send_calls) == 1 + assert hass._last_chat_log is not None + assert hass._last_chat_log.added[-1].content == "Fallback reply" + + +def test_agent_advertises_streaming_support(conversation_module) -> None: + agent, _, _ = _make_agent(conversation_module, client=FakeClient()) + + assert agent.supports_streaming is True + assert agent._attr_supports_streaming is True + + +def test_conversation_id_resolution_keeps_agent_namespace(conversation_module) -> None: + agent, _, _ = _make_agent(conversation_module, client=FakeClient()) + user_input = _make_user_input(text="Hello there", conversation_id="session-42") + + assert agent._resolve_conversation_id(user_input, "assistant-a") == "session-42:assistant-a" + + +def test_question_replies_keep_continue_conversation(conversation_module) -> None: + client = FakeClient(stream_chunks=["Need anything else?"]) + agent, _, _ = _make_agent(conversation_module, client=client) + + result = asyncio.run(agent.async_process(_make_user_input(text="Hello there"))) + + assert result.continue_conversation is True From ee12048955e78195a5e77bdb60d2e81551a8d780 Mon Sep 17 00:00:00 2001 From: Lukasz Czarnecki <6866304+lukcz@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:13:32 +0200 Subject: [PATCH 2/6] Avoid duplicate ConversationEntity agent registration --- custom_components/openclaw/conversation.py | 10 ---------- tests/test_conversation_streaming.py | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index fc05f2d..e688e97 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -87,16 +87,6 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._attr_unique_id = entry.entry_id self._attr_name = entry.title or "OpenClaw" - async def async_added_to_hass(self) -> None: - """Register the entity-backed agent when added to Home Assistant.""" - await super().async_added_to_hass() - conversation.async_set_agent(self.hass, self.entry, self) - - async def async_will_remove_from_hass(self) -> None: - """Unregister the entity-backed agent when removed from Home Assistant.""" - conversation.async_unset_agent(self.hass, self.entry) - await super().async_will_remove_from_hass() - @property def attribution(self) -> dict[str, str]: """Return attribution info.""" diff --git a/tests/test_conversation_streaming.py b/tests/test_conversation_streaming.py index 4f62ce8..95fb4db 100644 --- a/tests/test_conversation_streaming.py +++ b/tests/test_conversation_streaming.py @@ -406,6 +406,25 @@ def test_agent_advertises_streaming_support(conversation_module) -> None: assert agent._attr_supports_streaming is True +def test_entity_agent_skips_legacy_agent_registration(conversation_module) -> None: + agent, _, _ = _make_agent(conversation_module, client=FakeClient()) + calls: list[str] = [] + + def _set_agent(*args: Any, **kwargs: Any) -> None: + calls.append("set") + + def _unset_agent(*args: Any, **kwargs: Any) -> None: + calls.append("unset") + + conversation_module.conversation.async_set_agent = _set_agent + conversation_module.conversation.async_unset_agent = _unset_agent + + asyncio.run(agent.async_added_to_hass()) + asyncio.run(agent.async_will_remove_from_hass()) + + assert calls == [] + + def test_conversation_id_resolution_keeps_agent_namespace(conversation_module) -> None: agent, _, _ = _make_agent(conversation_module, client=FakeClient()) user_input = _make_user_input(text="Hello there", conversation_id="session-42") From 392e3b75dfc00090aa9f911c1cad38cd23e4e918 Mon Sep 17 00:00:00 2001 From: Lukasz Czarnecki <6866304+lukcz@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:49:44 +0200 Subject: [PATCH 3/6] Stream chat card messages through Assist pipeline --- custom_components/openclaw/__init__.py | 3 + custom_components/openclaw/conversation.py | 17 ++ .../openclaw/www/openclaw-chat-card.js | 245 +++++++++++++++--- tests/test_conversation_streaming.py | 10 +- 4 files changed, 242 insertions(+), 33 deletions(-) diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 99e5e46..d07dfd0 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -838,6 +838,9 @@ def websocket_get_settings( DEFAULT_THINKING_TIMEOUT, ), "language": hass.config.language, + "conversation_entity_id": entry_data.get("conversation_entity_id") + if entry_data + else None, }, ) diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index e688e97..ac923c1 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -87,6 +87,23 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._attr_unique_id = entry.entry_id self._attr_name = entry.title or "OpenClaw" + async def async_added_to_hass(self) -> None: + """Expose the entity id for frontend pipeline selection.""" + await super().async_added_to_hass() + entry_data = self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id) + if entry_data is not None: + entry_data["conversation_entity_id"] = self.entity_id + + async def async_will_remove_from_hass(self) -> None: + """Remove the cached entity id when the agent unloads.""" + entry_data = self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id) + if ( + entry_data is not None + and entry_data.get("conversation_entity_id") == self.entity_id + ): + entry_data.pop("conversation_entity_id", None) + await super().async_will_remove_from_hass() + @property def attribution(self) -> dict[str, str]: """Return attribution info.""" diff --git a/custom_components/openclaw/www/openclaw-chat-card.js b/custom_components/openclaw/www/openclaw-chat-card.js index a9ab1c4..700d539 100644 --- a/custom_components/openclaw/www/openclaw-chat-card.js +++ b/custom_components/openclaw/www/openclaw-chat-card.js @@ -9,11 +9,12 @@ * - Voice input (WebSpeech / MediaRecorder) * - Voice mode toggle (continuous conversation) * - * Communication: uses HA WebSocket API → openclaw.send_message service - * + subscribes to openclaw_message_received events. + * Communication: prefers Assist pipeline text streaming when an OpenClaw + * conversation pipeline is available, and falls back to the legacy + * openclaw.send_message service plus openclaw_message_received events. */ -const CARD_VERSION = "0.3.13"; +const CARD_VERSION = "0.3.14"; // Max time (ms) to show the thinking indicator before falling back to an error (default; overridable via card config `thinking_timeout` in seconds) const THINKING_TIMEOUT_MS = 120_000; @@ -78,6 +79,9 @@ class OpenClawChatCard extends HTMLElement { this._voiceProviderIntegration = "browser"; this._preferredAssistSttEngine = null; this._preferredAssistTtsEngine = null; + this._preferredAssistPipelineId = null; + this._assistTextStreamingAvailable = false; + this._openclawConversationEntityId = null; this._assistTtsEngines = []; this._lastHaTtsAttempt = null; this._voiceBackendBlocked = false; @@ -93,6 +97,7 @@ class OpenClawChatCard extends HTMLElement { this._assistInputSampleRate = 16000; this._assistAutoStopTimer = null; this._lastAssistantEventSignature = null; + this._pipelineRunUnsubscribe = null; this._autoScrollPinned = true; this._lastHassRenderSignature = null; this._integrationThinkingTimeout = null; @@ -224,6 +229,10 @@ class OpenClawChatCard extends HTMLElement { clearTimeout(this._voiceRetryTimer); this._voiceRetryTimer = null; } + if (this._pipelineRunUnsubscribe) { + this._pipelineRunUnsubscribe(); + this._pipelineRunUnsubscribe = null; + } } // ── Event subscription ────────────────────────────────────────────── @@ -426,6 +435,11 @@ class OpenClawChatCard extends HTMLElement { typeof result?.thinking_timeout === "number" && result.thinking_timeout >= 10 ? result.thinking_timeout * 1000 : null; + this._openclawConversationEntityId = + typeof result?.conversation_entity_id === "string" && + result.conversation_entity_id.trim().length > 0 + ? result.conversation_entity_id.trim() + : null; if (includePipeline) { try { @@ -442,13 +456,24 @@ class OpenClawChatCard extends HTMLElement { const pipelines = Array.isArray(pipelineResult?.pipelines) ? pipelineResult.pipelines : []; + const openClawPipeline = this._openclawConversationEntityId + ? pipelines.find( + (pipeline) => pipeline?.conversation_engine === this._openclawConversationEntityId + ) || null + : null; + const preferredPipeline = + openClawPipeline || + pipelines.find((pipeline) => pipeline?.id === preferredPipelineId) || + pipelines[0]; + this._preferredAssistPipelineId = preferredPipeline?.id || null; + this._assistTextStreamingAvailable = + !!openClawPipeline && + preferredPipeline?.conversation_engine === this._openclawConversationEntityId; const discoveredTtsEngines = pipelines .map((pipeline) => pipeline?.tts_engine) .filter((engine) => typeof engine === "string" && engine.trim().length > 0) .map((engine) => engine.trim()); this._assistTtsEngines = [...new Set(discoveredTtsEngines)]; - const preferredPipeline = - pipelines.find((pipeline) => pipeline?.id === preferredPipelineId) || pipelines[0]; this._preferredAssistSttEngine = preferredPipeline?.stt_engine || null; this._preferredAssistTtsEngine = preferredPipeline?.tts_engine || this._assistTtsEngines[0] || null; @@ -516,56 +541,212 @@ class OpenClawChatCard extends HTMLElement { this._persistMessages(); } - async _sendMessage(text, source = null) { - if (!text || !text.trim() || !this._hass) return; + _canUseAssistPipelineTextStreaming() { + return !!( + this._hass?.connection?.subscribeMessage && + this._preferredAssistPipelineId && + this._assistTextStreamingAvailable + ); + } + _extractAssistPipelineSpeech(eventData) { + const plainSpeech = eventData?.intent_output?.response?.speech?.plain?.speech; + if (typeof plainSpeech === "string" && plainSpeech.length) { + return plainSpeech; + } + + const simpleSpeech = eventData?.intent_output?.speech?.plain?.speech; + if (typeof simpleSpeech === "string" && simpleSpeech.length) { + return simpleSpeech; + } + + return null; + } + + async _sendMessageViaLegacyService(message, source = null) { await this._subscribeToEvents(); + await this._hass.callService("openclaw", "send_message", { + message: message, + source: source || undefined, + session_id: this._config.session_id || undefined, + }); + + setTimeout(() => { + this._syncHistoryFromBackend(1); + }, 1200); + } + + async _sendMessageViaAssistPipeline(message, assistantMessage) { + const command = { + type: "assist_pipeline/run", + start_stage: "intent", + end_stage: "intent", + input: { text: message }, + conversation_id: this._getSessionId(), + pipeline: this._preferredAssistPipelineId, + }; + + return new Promise(async (resolve, reject) => { + let settled = false; + let sawDeltaContent = false; + let unsubscribe = null; + + const redraw = () => { + this._persistMessages(); + this._render(); + this._scrollToBottom(); + }; + + const finish = (err = null) => { + if (settled) return; + settled = true; + if (unsubscribe) { + unsubscribe(); + if (this._pipelineRunUnsubscribe === unsubscribe) { + this._pipelineRunUnsubscribe = null; + } + unsubscribe = null; + } + if (err) { + reject(err); + return; + } + resolve(); + }; + + try { + unsubscribe = await this._hass.connection.subscribeMessage((messageEvent) => { + const event = messageEvent?.event && typeof messageEvent.event === "object" + ? messageEvent.event + : messageEvent; + const eventType = event?.type; + const eventData = event?.data || {}; + + if (!eventType || eventType === "result") { + return; + } + + if (eventType === "intent-progress") { + const delta = eventData?.chat_log_delta; + if (!delta || typeof delta.content !== "string" || !delta.content.length) { + return; + } + + assistantMessage.role = "assistant"; + assistantMessage.content = `${assistantMessage._thinking ? "" : assistantMessage.content || ""}${delta.content}`; + assistantMessage._thinking = false; + sawDeltaContent = true; + redraw(); + return; + } + + if (eventType === "intent-end") { + const finalSpeech = this._extractAssistPipelineSpeech(eventData); + if (typeof finalSpeech === "string" && finalSpeech.length && !sawDeltaContent) { + assistantMessage.role = "assistant"; + assistantMessage.content = finalSpeech; + assistantMessage._thinking = false; + redraw(); + } + return; + } + + if (eventType === "error") { + finish(new Error(eventData?.message || "Assist pipeline failed")); + return; + } + + if (eventType === "run-end") { + if (assistantMessage._thinking && !assistantMessage.content) { + const finalSpeech = this._extractAssistPipelineSpeech(eventData); + if (typeof finalSpeech === "string" && finalSpeech.length) { + assistantMessage.role = "assistant"; + assistantMessage.content = finalSpeech; + assistantMessage._thinking = false; + redraw(); + } + } + + if (assistantMessage._thinking && !assistantMessage.content) { + finish(new Error("No response received from Assist pipeline")); + return; + } + + finish(); + } + }, command); + this._pipelineRunUnsubscribe = unsubscribe; + } catch (err) { + finish(err); + } + }); + } + + async _sendMessage(text, source = null) { + if (!text || !text.trim() || !this._hass) return; + const message = text.trim(); this._addMessage("user", message); // Add thinking indicator with timeout safeguard this._isProcessing = true; this._pendingResponses += 1; - this._messages.push({ + const assistantMessage = { role: "assistant", content: "", _thinking: true, timestamp: new Date().toISOString(), - }); + }; + this._messages.push(assistantMessage); this._startThinkingTimer(); this._render(); this._scrollToBottom(); + const useAssistPipeline = this._canUseAssistPipelineTextStreaming(); + try { - await this._hass.callService("openclaw", "send_message", { - message: message, - source: source || undefined, - session_id: this._config.session_id || undefined, - }); + if (useAssistPipeline) { + await this._sendMessageViaAssistPipeline(message, assistantMessage); + if (this._pendingResponses > 0) { + this._pendingResponses -= 1; + } + this._isProcessing = this._pendingResponses > 0; + if (!this._isProcessing) { + this._clearThinkingTimer(); + } + this._persistMessages(); + this._render(); + this._scrollToBottom(); - setTimeout(() => { - this._syncHistoryFromBackend(1); - }, 1200); - } catch (err) { - console.error("OpenClaw: Failed to send message:", err); - // Replace thinking with error - let thinkingIdx = -1; - for (let idx = this._messages.length - 1; idx >= 0; idx -= 1) { - if (this._messages[idx]?._thinking) { - thinkingIdx = idx; - break; + if (this._isVoiceMode && assistantMessage.content) { + this._speak(assistantMessage.content); } + } else { + await this._sendMessageViaLegacyService(message, source); } - if (thinkingIdx >= 0) { - this._messages[thinkingIdx] = { - role: "assistant", - content: `Error: ${err.message || "Failed to send message"}`, - timestamp: new Date().toISOString(), - _error: true, - }; + } catch (err) { + if (useAssistPipeline) { + console.warn( + "OpenClaw: Assist pipeline text streaming failed, falling back to legacy send_message:", + err + ); + try { + await this._sendMessageViaLegacyService(message, source); + return; + } catch (fallbackErr) { + err = fallbackErr; + } } + + console.error("OpenClaw: Failed to send message:", err); + // Replace thinking with error + assistantMessage.role = "assistant"; + assistantMessage.content = `Error: ${err.message || "Failed to send message"}`; + assistantMessage.timestamp = new Date().toISOString(); + assistantMessage._thinking = false; + assistantMessage._error = true; if (this._pendingResponses > 0) { this._pendingResponses -= 1; } diff --git a/tests/test_conversation_streaming.py b/tests/test_conversation_streaming.py index 95fb4db..27f1a58 100644 --- a/tests/test_conversation_streaming.py +++ b/tests/test_conversation_streaming.py @@ -407,7 +407,7 @@ def test_agent_advertises_streaming_support(conversation_module) -> None: def test_entity_agent_skips_legacy_agent_registration(conversation_module) -> None: - agent, _, _ = _make_agent(conversation_module, client=FakeClient()) + agent, hass, _ = _make_agent(conversation_module, client=FakeClient()) calls: list[str] = [] def _set_agent(*args: Any, **kwargs: Any) -> None: @@ -420,9 +420,17 @@ def _unset_agent(*args: Any, **kwargs: Any) -> None: conversation_module.conversation.async_unset_agent = _unset_agent asyncio.run(agent.async_added_to_hass()) + assert ( + hass.data[conversation_module.DOMAIN][agent.entry.entry_id]["conversation_entity_id"] + == "conversation.openclaw" + ) asyncio.run(agent.async_will_remove_from_hass()) assert calls == [] + assert ( + "conversation_entity_id" + not in hass.data[conversation_module.DOMAIN][agent.entry.entry_id] + ) def test_conversation_id_resolution_keeps_agent_namespace(conversation_module) -> None: From bcc14cc32ede34c47d56bfbf49052ecd69364576 Mon Sep 17 00:00:00 2001 From: Lukasz Czarnecki <6866304+lukcz@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:58:13 +0200 Subject: [PATCH 4/6] Revert unrelated continue conversation heuristic tweak --- custom_components/openclaw/conversation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index ac923c1..6f8042b 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -466,8 +466,8 @@ def _should_continue(response: str) -> bool: text = response.strip() # Check if the response ends with a question mark - # (allow trailing punctuation like quotes or parens) - if re.search(r"\?\s*['\")\]]*\s*$", text): + # (allow trailing punctuation like quotes, parens, or emoji) + if re.search(r"\?\s*[\"'""»)\]]*\s*$", text): return True # Common follow-up patterns (EN + DE) From 0c14568d91e6bfbef5535d4aed1b7f6919736762 Mon Sep 17 00:00:00 2001 From: Lukasz Czarnecki <6866304+lukcz@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:05:56 +0200 Subject: [PATCH 5/6] Support legacy Assist pipeline ids in chat card streaming --- custom_components/openclaw/__init__.py | 5 +++++ custom_components/openclaw/conversation.py | 11 +++++----- .../openclaw/www/openclaw-chat-card.js | 22 +++++++++++++++---- tests/test_conversation_streaming.py | 8 +++++++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index d07dfd0..9418e6c 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -841,6 +841,11 @@ def websocket_get_settings( "conversation_entity_id": entry_data.get("conversation_entity_id") if entry_data else None, + "legacy_conversation_agent_id": entry_data.get( + "legacy_conversation_agent_id" + ) + if entry_data + else None, }, ) diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 6f8042b..bacc2de 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -93,15 +93,16 @@ async def async_added_to_hass(self) -> None: entry_data = self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id) if entry_data is not None: entry_data["conversation_entity_id"] = self.entity_id + entry_data["legacy_conversation_agent_id"] = self.entry.entry_id async def async_will_remove_from_hass(self) -> None: """Remove the cached entity id when the agent unloads.""" entry_data = self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id) - if ( - entry_data is not None - and entry_data.get("conversation_entity_id") == self.entity_id - ): - entry_data.pop("conversation_entity_id", None) + if entry_data is not None: + if entry_data.get("conversation_entity_id") == self.entity_id: + entry_data.pop("conversation_entity_id", None) + if entry_data.get("legacy_conversation_agent_id") == self.entry.entry_id: + entry_data.pop("legacy_conversation_agent_id", None) await super().async_will_remove_from_hass() @property diff --git a/custom_components/openclaw/www/openclaw-chat-card.js b/custom_components/openclaw/www/openclaw-chat-card.js index 700d539..a4c8067 100644 --- a/custom_components/openclaw/www/openclaw-chat-card.js +++ b/custom_components/openclaw/www/openclaw-chat-card.js @@ -82,6 +82,7 @@ class OpenClawChatCard extends HTMLElement { this._preferredAssistPipelineId = null; this._assistTextStreamingAvailable = false; this._openclawConversationEntityId = null; + this._openclawLegacyConversationAgentId = null; this._assistTtsEngines = []; this._lastHaTtsAttempt = null; this._voiceBackendBlocked = false; @@ -440,6 +441,11 @@ class OpenClawChatCard extends HTMLElement { result.conversation_entity_id.trim().length > 0 ? result.conversation_entity_id.trim() : null; + this._openclawLegacyConversationAgentId = + typeof result?.legacy_conversation_agent_id === "string" && + result.legacy_conversation_agent_id.trim().length > 0 + ? result.legacy_conversation_agent_id.trim() + : null; if (includePipeline) { try { @@ -456,9 +462,13 @@ class OpenClawChatCard extends HTMLElement { const pipelines = Array.isArray(pipelineResult?.pipelines) ? pipelineResult.pipelines : []; - const openClawPipeline = this._openclawConversationEntityId - ? pipelines.find( - (pipeline) => pipeline?.conversation_engine === this._openclawConversationEntityId + const openClawConversationEngines = [ + this._openclawConversationEntityId, + this._openclawLegacyConversationAgentId, + ].filter((value) => typeof value === "string" && value.length > 0); + const openClawPipeline = openClawConversationEngines.length + ? pipelines.find((pipeline) => + openClawConversationEngines.includes(pipeline?.conversation_engine) ) || null : null; const preferredPipeline = @@ -468,7 +478,7 @@ class OpenClawChatCard extends HTMLElement { this._preferredAssistPipelineId = preferredPipeline?.id || null; this._assistTextStreamingAvailable = !!openClawPipeline && - preferredPipeline?.conversation_engine === this._openclawConversationEntityId; + openClawConversationEngines.includes(preferredPipeline?.conversation_engine); const discoveredTtsEngines = pipelines .map((pipeline) => pipeline?.tts_engine) .filter((engine) => typeof engine === "string" && engine.trim().length > 0) @@ -686,6 +696,10 @@ class OpenClawChatCard extends HTMLElement { async _sendMessage(text, source = null) { if (!text || !text.trim() || !this._hass) return; + if (!this._preferredAssistPipelineId) { + await this._loadIntegrationSettings(true); + } + const message = text.trim(); this._addMessage("user", message); diff --git a/tests/test_conversation_streaming.py b/tests/test_conversation_streaming.py index 27f1a58..9835f12 100644 --- a/tests/test_conversation_streaming.py +++ b/tests/test_conversation_streaming.py @@ -424,6 +424,10 @@ def _unset_agent(*args: Any, **kwargs: Any) -> None: hass.data[conversation_module.DOMAIN][agent.entry.entry_id]["conversation_entity_id"] == "conversation.openclaw" ) + assert ( + hass.data[conversation_module.DOMAIN][agent.entry.entry_id]["legacy_conversation_agent_id"] + == "entry-1" + ) asyncio.run(agent.async_will_remove_from_hass()) assert calls == [] @@ -431,6 +435,10 @@ def _unset_agent(*args: Any, **kwargs: Any) -> None: "conversation_entity_id" not in hass.data[conversation_module.DOMAIN][agent.entry.entry_id] ) + assert ( + "legacy_conversation_agent_id" + not in hass.data[conversation_module.DOMAIN][agent.entry.entry_id] + ) def test_conversation_id_resolution_keeps_agent_namespace(conversation_module) -> None: From 5a2eb4a4d947df8d5cd7238de44fb8c804761f37 Mon Sep 17 00:00:00 2001 From: Lukasz Czarnecki <6866304+lukcz@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:24:48 +0200 Subject: [PATCH 6/6] Refresh chat card pipeline settings before send --- custom_components/openclaw/__init__.py | 2 +- custom_components/openclaw/manifest.json | 2 +- custom_components/openclaw/www/openclaw-chat-card.js | 7 ++----- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 9418e6c..2854b0a 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -107,7 +107,7 @@ # URL at which the card JS is served (registered via register_static_path) _CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}" # Versioned URL used for Lovelace resource registration to avoid stale browser cache -_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.62" +_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.63" OpenClawConfigEntry = ConfigEntry diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json index 7f6650d..e5ba780 100644 --- a/custom_components/openclaw/manifest.json +++ b/custom_components/openclaw/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistantIntegration/issues", "requirements": [], - "version": "0.1.62", + "version": "0.1.63", "dependencies": ["conversation"], "after_dependencies": ["hassio", "lovelace"] } diff --git a/custom_components/openclaw/www/openclaw-chat-card.js b/custom_components/openclaw/www/openclaw-chat-card.js index a4c8067..f653d0a 100644 --- a/custom_components/openclaw/www/openclaw-chat-card.js +++ b/custom_components/openclaw/www/openclaw-chat-card.js @@ -14,7 +14,7 @@ * openclaw.send_message service plus openclaw_message_received events. */ -const CARD_VERSION = "0.3.14"; +const CARD_VERSION = "0.3.17"; // Max time (ms) to show the thinking indicator before falling back to an error (default; overridable via card config `thinking_timeout` in seconds) const THINKING_TIMEOUT_MS = 120_000; @@ -695,10 +695,7 @@ class OpenClawChatCard extends HTMLElement { async _sendMessage(text, source = null) { if (!text || !text.trim() || !this._hass) return; - - if (!this._preferredAssistPipelineId) { - await this._loadIntegrationSettings(true); - } + await this._loadIntegrationSettings(true); const message = text.trim(); this._addMessage("user", message);