diff --git a/CHANGELOG.md b/CHANGELOG.md index baa1ce7..8d274a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to the OpenClaw Home Assistant Integration will be documented in this file. +## [0.1.66] - 2026-05-06 + +### Added +- Added Home Assistant user-aware OpenClaw agent routing for the chat card and Assist sessions. +- Added a dedicated `openclaw/send_message` websocket command so non-admin dashboard users receive chat replies without subscribing to custom event-bus events. + +### Fixed +- Fixed chat history isolation by scoping backend and frontend history by Home Assistant user, resolved agent, and raw session ID. +- Fixed OpenClaw session keys to use canonical `agent::ha:user::session:` values so routed chat messages do not fall back into `main/default`. +- Strengthened chat-card cache busting with a versioned static resource path and disabled static-path cache headers. + ## [0.1.62] - 2026-04-04 ### Added diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index d1e2db7..2b825d3 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -91,6 +91,7 @@ from .exposure import apply_context_policy, build_exposed_entities_context from .helpers import extract_text_recursive from .routing import ( build_scoped_session_id, + build_scoped_session_prefix, normalize_ha_user_id, normalize_optional_text, resolve_agent_id, @@ -111,9 +112,11 @@ _VOICE_REQUEST_HEADERS = { _CARD_FILENAME = "openclaw-chat-card.js" _CARD_PATH = Path(__file__).parent / "www" / _CARD_FILENAME # 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.63" +_CARD_STATIC_URL = "/openclaw/openclaw-chat-card-0.1.66.js" +# Versioned URL used for Lovelace resource registration to avoid stale browser cache. +# The path itself carries the version because HA/Lovelace can keep old custom +# card modules alive more aggressively than ordinary query-string cache busting. +_CARD_URL = _CARD_STATIC_URL OpenClawConfigEntry = ConfigEntry @@ -324,10 +327,10 @@ async def _async_register_static_path(hass: HomeAssistant) -> str | None: from homeassistant.components.http import StaticPathConfig # noqa: PLC0415 await hass.http.async_register_static_paths( - [StaticPathConfig(_CARD_STATIC_URL, str(_CARD_PATH), cache_headers=True)] + [StaticPathConfig(_CARD_STATIC_URL, str(_CARD_PATH), cache_headers=False)] ) except (ImportError, AttributeError): - hass.http.register_static_path(_CARD_STATIC_URL, str(_CARD_PATH), True) + hass.http.register_static_path(_CARD_STATIC_URL, str(_CARD_PATH), False) hass.data[static_key] = True _LOGGER.debug("Registered static path: %s", _CARD_STATIC_URL) @@ -358,7 +361,9 @@ async def _async_add_lovelace_resource(hass: HomeAssistant, url: str) -> bool: desired_path = urlsplit(url).path legacy_paths = { + _CARD_STATIC_URL, "/openclaw/openclaw-chat-card.js", + "/openclaw/openclaw-chat-card-0.1.65.js", "/local/openclaw-chat-card.js", "/hacsfiles/openclaw/openclaw-chat-card.js", } @@ -402,6 +407,112 @@ async def _async_add_lovelace_resource(hass: HomeAssistant, url: str) -> bool: # ── Service registration ────────────────────────────────────────────────────── + +async def _async_send_openclaw_chat( + hass: HomeAssistant, + *, + message: str, + source: str | None, + raw_session_id: str, + ha_user_id: str, + call_agent_id: str | None = None, +) -> dict[str, Any]: + """Send one chat message to OpenClaw and return the routed response payload.""" + extra_headers = _VOICE_REQUEST_HEADERS if source == "voice" else None + + entry_data = _get_first_entry_data(hass) + if not entry_data: + raise OpenClawApiError("No OpenClaw integration configured") + + client: OpenClawApiClient = entry_data["client"] + coordinator: OpenClawCoordinator = entry_data["coordinator"] + options = _get_entry_options(hass, entry_data) + resolved_agent_id = resolve_agent_id(ha_user_id, call_agent_id) + scoped_session_id = build_scoped_session_id( + raw_session_id, + ha_user_id, + resolved_agent_id, + ) + _LOGGER.debug( + "OpenClaw chat routing: context_user=%s call_agent=%s " + "resolved_agent=%s raw_session=%s scoped_session=%s source=%s", + ha_user_id, + call_agent_id, + resolved_agent_id, + raw_session_id, + scoped_session_id, + source, + ) + + include_context = options.get( + CONF_INCLUDE_EXPOSED_CONTEXT, + DEFAULT_INCLUDE_EXPOSED_CONTEXT, + ) + max_chars = int(options.get(CONF_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS)) + strategy = options.get(CONF_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY) + if strategy not in {"clear", CONTEXT_STRATEGY_TRUNCATE}: + strategy = DEFAULT_CONTEXT_STRATEGY + + raw_context = ( + build_exposed_entities_context(hass, assistant="conversation") + if include_context + else None + ) + system_prompt = apply_context_policy(raw_context, max_chars, strategy) + active_model = resolve_model_override(options.get("active_model")) + + _append_chat_history(hass, scoped_session_id, "user", message) + + response = await client.async_send_message( + message=message, + session_id=scoped_session_id, + system_prompt=system_prompt, + agent_id=resolved_agent_id, + model=active_model, + extra_headers=extra_headers, + ) + + if options.get(CONF_ENABLE_TOOL_CALLS, DEFAULT_ENABLE_TOOL_CALLS): + tool_results = await _async_execute_tool_calls(hass, response) + if tool_results: + response = await client.async_send_message( + message=( + "Tool execution results:\n" + + "\n".join(f"- {line}" for line in tool_results) + + "\nRespond to the user based on these results." + ), + session_id=scoped_session_id, + system_prompt=system_prompt, + agent_id=resolved_agent_id, + model=active_model, + extra_headers=extra_headers, + ) + + assistant_message = _extract_assistant_message(response) + model_used = response.get("model", "unknown") + + if not assistant_message: + assistant_message = "Response received, but no readable message content was found." + _LOGGER.warning( + "OpenClaw response had no parseable assistant message. Keys: %s", + list(response.keys()), + ) + + _append_chat_history(hass, scoped_session_id, "assistant", assistant_message) + coordinator.update_last_activity() + + return { + ATTR_MESSAGE: assistant_message, + ATTR_SESSION_ID: raw_session_id, + ATTR_SESSION_KEY: scoped_session_id, + ATTR_HA_USER_ID: ha_user_id, + ATTR_AGENT_ID: resolved_agent_id, + ATTR_MODEL: model_used, + ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(), + "messages": _get_chat_history_store(hass).get(scoped_session_id, []), + } + + @callback def _async_register_services(hass: HomeAssistant) -> None: """Register openclaw.send_message and openclaw.clear_history services.""" @@ -413,16 +524,6 @@ def _async_register_services(hass: HomeAssistant) -> None: raw_session_id: str = call.data.get(ATTR_SESSION_ID) or "default" ha_user_id = normalize_ha_user_id(call.context.user_id) call_agent_id = normalize_optional_text(call.data.get(ATTR_AGENT_ID)) - extra_headers = _VOICE_REQUEST_HEADERS if source == "voice" else None - - entry_data = _get_first_entry_data(hass) - if not entry_data: - _LOGGER.error("No OpenClaw integration configured") - return - - client: OpenClawApiClient = entry_data["client"] - coordinator: OpenClawCoordinator = entry_data["coordinator"] - options = _get_entry_options(hass, entry_data) resolved_agent_id = resolve_agent_id(ha_user_id, call_agent_id) scoped_session_id = build_scoped_session_id( raw_session_id, @@ -431,77 +532,15 @@ def _async_register_services(hass: HomeAssistant) -> None: ) try: - include_context = options.get( - CONF_INCLUDE_EXPOSED_CONTEXT, - DEFAULT_INCLUDE_EXPOSED_CONTEXT, - ) - max_chars = int( - options.get(CONF_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS) - ) - strategy = options.get(CONF_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY) - if strategy not in {"clear", CONTEXT_STRATEGY_TRUNCATE}: - strategy = DEFAULT_CONTEXT_STRATEGY - - raw_context = ( - build_exposed_entities_context(hass, assistant="conversation") - if include_context - else None - ) - system_prompt = apply_context_policy(raw_context, max_chars, strategy) - - active_model = resolve_model_override(options.get("active_model")) - - _append_chat_history(hass, scoped_session_id, "user", message) - - response = await client.async_send_message( + result = await _async_send_openclaw_chat( + hass, message=message, - session_id=scoped_session_id, - system_prompt=system_prompt, - agent_id=resolved_agent_id, - model=active_model, - extra_headers=extra_headers, + source=source, + raw_session_id=raw_session_id, + ha_user_id=ha_user_id, + call_agent_id=call_agent_id, ) - - if options.get(CONF_ENABLE_TOOL_CALLS, DEFAULT_ENABLE_TOOL_CALLS): - tool_results = await _async_execute_tool_calls(hass, response) - if tool_results: - response = await client.async_send_message( - message=( - "Tool execution results:\n" - + "\n".join(f"- {line}" for line in tool_results) - + "\nRespond to the user based on these results." - ), - session_id=scoped_session_id, - system_prompt=system_prompt, - agent_id=resolved_agent_id, - model=active_model, - extra_headers=extra_headers, - ) - - assistant_message = _extract_assistant_message(response) - model_used = response.get("model", "unknown") - - if not assistant_message: - assistant_message = "Response received, but no readable message content was found." - _LOGGER.warning( - "OpenClaw response had no parseable assistant message. Keys: %s", - list(response.keys()), - ) - - _append_chat_history(hass, scoped_session_id, "assistant", assistant_message) - hass.bus.async_fire( - EVENT_MESSAGE_RECEIVED, - { - ATTR_MESSAGE: assistant_message, - ATTR_SESSION_ID: raw_session_id, - ATTR_SESSION_KEY: scoped_session_id, - ATTR_HA_USER_ID: ha_user_id, - ATTR_AGENT_ID: resolved_agent_id, - ATTR_MODEL: model_used, - ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(), - }, - ) - coordinator.update_last_activity() + hass.bus.async_fire(EVENT_MESSAGE_RECEIVED, result) except OpenClawApiError as err: _LOGGER.error("Failed to send message to OpenClaw: %s", err) @@ -545,10 +584,7 @@ def _async_register_services(hass: HomeAssistant) -> None: ) store.pop(scoped_session_id, None) else: - scoped_prefix = build_scoped_session_id("", ha_user_id, agent_id).rsplit( - "__session_", - 1, - )[0] + "__session_" + scoped_prefix = build_scoped_session_prefix(ha_user_id, agent_id) for key in list(store): if key.startswith(scoped_prefix): store.pop(key, None) @@ -848,6 +884,45 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_get_history) + @websocket_api.websocket_command( + { + vol.Required("type"): f"{DOMAIN}/send_message", + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_SOURCE): cv.string, + vol.Optional(ATTR_SESSION_ID): cv.string, + vol.Optional(ATTR_AGENT_ID): cv.string, + } + ) + @websocket_api.async_response + async def websocket_send_message( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: + """Send a chat message and return the assistant reply to the caller.""" + raw_session_id = msg.get(ATTR_SESSION_ID) or "default" + source = normalize_optional_text(msg.get(ATTR_SOURCE)) + requested_agent_id = normalize_optional_text(msg.get(ATTR_AGENT_ID)) + connection_user = getattr(connection, "user", None) + ha_user_id = normalize_ha_user_id(getattr(connection_user, "id", None)) + + try: + result = await _async_send_openclaw_chat( + hass, + message=msg[ATTR_MESSAGE], + source=source, + raw_session_id=raw_session_id, + ha_user_id=ha_user_id, + call_agent_id=requested_agent_id, + ) + except OpenClawApiError as err: + connection.send_error(msg["id"], "openclaw_error", str(err)) + return + + connection.send_result(msg["id"], result) + + websocket_api.async_register_command(hass, websocket_send_message) + @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/get_settings", diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 8eef923..2046b67 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -41,7 +41,12 @@ from .const import ( from .coordinator import OpenClawCoordinator from .exposure import apply_context_policy, build_exposed_entities_context from .helpers import extract_text_recursive -from .routing import normalize_ha_user_id, resolve_agent_id, resolve_model_override +from .routing import ( + build_scoped_session_id, + normalize_ha_user_id, + resolve_agent_id, + resolve_model_override, +) _LOGGER = logging.getLogger(__name__) @@ -126,7 +131,11 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): ha_user_id = normalize_ha_user_id(getattr(context, "user_id", None)) options = self.entry.options resolved_agent_id = resolve_agent_id(ha_user_id) - conversation_id = self._resolve_conversation_id(user_input, resolved_agent_id) + conversation_id = self._resolve_conversation_id( + user_input, + ha_user_id, + resolved_agent_id, + ) active_model = resolve_model_override(options.get("active_model")) include_context = options.get( CONF_INCLUDE_EXPOSED_CONTEXT, @@ -218,9 +227,10 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): def _resolve_conversation_id( self, user_input: conversation.ConversationInput, + ha_user_id: str | None, agent_id: str | None, ) -> str: - """Return conversation id from HA with conservative agent namespacing.""" + """Return an OpenClaw agent-scoped session key for HA Assist.""" configured_session_id = self._normalize_optional_text( self.entry.options.get( CONF_ASSIST_SESSION_ID, @@ -228,27 +238,37 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): ) ) if configured_session_id: - return configured_session_id + if configured_session_id.lower().startswith("agent:"): + return configured_session_id + return build_scoped_session_id(configured_session_id, ha_user_id, agent_id) agent_suffix = self._normalize_optional_text(agent_id) if user_input.conversation_id: - if agent_suffix: - return f"{user_input.conversation_id}:{agent_suffix}" - return user_input.conversation_id + if user_input.conversation_id.lower().startswith("agent:"): + return user_input.conversation_id + raw_session = ( + f"{user_input.conversation_id}:{agent_suffix}" + if agent_suffix + else user_input.conversation_id + ) + return build_scoped_session_id(raw_session, ha_user_id, agent_id) context = getattr(user_input, "context", None) user_id = getattr(context, "user_id", None) if user_id: base_id = f"assist_user_{user_id}" - return f"{base_id}:{agent_suffix}" if agent_suffix else base_id + raw_session = f"{base_id}:{agent_suffix}" if agent_suffix else base_id + return build_scoped_session_id(raw_session, ha_user_id, agent_id) device_id = getattr(user_input, "device_id", None) if device_id: base_id = f"assist_device_{device_id}" - return f"{base_id}:{agent_suffix}" if agent_suffix else base_id + raw_session = f"{base_id}:{agent_suffix}" if agent_suffix else base_id + return build_scoped_session_id(raw_session, ha_user_id, agent_id) - return f"assist_default:{agent_suffix}" if agent_suffix else "assist_default" + raw_session = f"assist_default:{agent_suffix}" if agent_suffix else "assist_default" + return build_scoped_session_id(raw_session, ha_user_id, agent_id) def _normalize_optional_text(self, value: Any) -> str | None: """Return a stripped string or None for blank values.""" @@ -310,7 +330,7 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): # Check if the response ends with a question mark # (allow trailing punctuation like quotes, parens, or emoji) - if re.search(r"\?\s*[\"'""»)\]]*\s*$", text): + if re.search("\\?\\s*[\"'»)\\]]*\\s*$", text): return True # Common follow-up patterns (EN + DE) diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json index e5ba780..4273de7 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.63", + "version": "0.1.66", "dependencies": ["conversation"], "after_dependencies": ["hassio", "lovelace"] } diff --git a/custom_components/openclaw/routing.py b/custom_components/openclaw/routing.py index 9f16488..dbb65ac 100644 --- a/custom_components/openclaw/routing.py +++ b/custom_components/openclaw/routing.py @@ -9,6 +9,7 @@ DEFAULT_CHAT_AGENT_ID = "tetra-home" SYSTEM_HA_USER_ID = "system" HA_USER_AGENT_MAP: dict[str, str] = { + # TODO: Move this user-to-agent map into config entry options. "bd3f666f43f941e886ef007a794eefe8": "main", "1d2be9a622584cf0abb1c243d12a9959": "family-krzysztof", "eca5887898544556840878ab37a35fbc": "family-ewa", @@ -54,18 +55,28 @@ def build_scoped_session_id( ha_user_id: str | None, agent_id: str | None, ) -> str: - """Build the backend/gateway session id scoped by user, agent, and raw session.""" + """Build an OpenClaw agent-scoped session key for HA user chat sessions.""" raw_session = normalize_optional_text(raw_session_id) or "default" user = normalize_ha_user_id(ha_user_id) agent = normalize_optional_text(agent_id) or DEFAULT_CHAT_AGENT_ID return ( - f"ha_user_{_sanitize_session_part(user)}" - f"__agent_{_sanitize_session_part(agent)}" - f"__session_{_sanitize_session_part(raw_session)}" + f"agent:{_sanitize_session_part(agent)}" + f":ha:user:{_sanitize_session_part(user)}" + f":session:{_sanitize_session_part(raw_session)}" ) +def build_scoped_session_prefix( + ha_user_id: str | None, + agent_id: str | None, +) -> str: + """Build the common OpenClaw session-key prefix for a HA user and agent.""" + user = normalize_ha_user_id(ha_user_id) + agent = normalize_optional_text(agent_id) or DEFAULT_CHAT_AGENT_ID + return f"agent:{_sanitize_session_part(agent)}:ha:user:{_sanitize_session_part(user)}:session:" + + def _sanitize_session_part(value: str) -> str: """Keep scoped session ids header-safe and readable.""" - cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", value.strip()) - return cleaned.strip("_") or "default" + cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", value.strip().lower()) + return cleaned.strip("._-") or "default" diff --git a/custom_components/openclaw/www/openclaw-chat-card.js b/custom_components/openclaw/www/openclaw-chat-card.js index 664d4f0..43023e0 100644 --- a/custom_components/openclaw/www/openclaw-chat-card.js +++ b/custom_components/openclaw/www/openclaw-chat-card.js @@ -9,11 +9,11 @@ * - 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: uses HA WebSocket API → openclaw/send_message and receives + * the assistant reply directly on the same request. */ -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; @@ -239,16 +239,9 @@ class OpenClawChatCard extends HTMLElement { // ── Event subscription ────────────────────────────────────────────── async _subscribeToEvents() { - if (!this._hass || this._eventUnsubscribe) return; - - try { - this._eventUnsubscribe = await this._hass.connection.subscribeEvents( - (event) => this._handleOpenClawEvent(event), - "openclaw_message_received" - ); - } catch (err) { - console.error("OpenClaw: Failed to subscribe to events:", err); - } + // Non-admin HA users cannot subscribe to arbitrary custom bus events. + // Chat replies are returned through the integration websocket command. + return; } _unsubscribeEvents() { @@ -416,12 +409,18 @@ class OpenClawChatCard extends HTMLElement { } const serverMessages = Array.isArray(result?.messages) ? result.messages : []; - if (!serverMessages.length) return; + if (!serverMessages.length) { + this._retryHistorySyncIfProcessing(retries); + return; + } const validMessages = serverMessages.filter( (m) => m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string" ); - if (!validMessages.length) return; + if (!validMessages.length) { + this._retryHistorySyncIfProcessing(retries); + return; + } const shouldReplace = validMessages.length > this._messages.length || @@ -441,20 +440,30 @@ class OpenClawChatCard extends HTMLElement { this._persistMessages(); this._render(); this._scrollToBottom(); + } else { + this._retryHistorySyncIfProcessing(retries); } } catch (err) { console.debug("OpenClaw: history sync skipped:", err); - if (retries > 0) { - if (this._historySyncRetryTimer) { - clearTimeout(this._historySyncRetryTimer); - } - this._historySyncRetryTimer = setTimeout(() => { - this._syncHistoryFromBackend(retries - 1); - }, 1500); - } + this._scheduleHistorySyncRetry(retries); } } + _retryHistorySyncIfProcessing(retries = 0) { + if (!this._isProcessing && this._pendingResponses <= 0) return; + this._scheduleHistorySyncRetry(retries); + } + + _scheduleHistorySyncRetry(retries = 0) { + if (retries <= 0) return; + if (this._historySyncRetryTimer) { + clearTimeout(this._historySyncRetryTimer); + } + this._historySyncRetryTimer = setTimeout(() => { + this._syncHistoryFromBackend(retries - 1); + }, 1500); + } + async _loadIntegrationSettings(includePipeline = true) { if (!this._hass) return; @@ -588,8 +597,6 @@ class OpenClawChatCard extends HTMLElement { async _sendMessage(text, source = null) { if (!text || !text.trim() || !this._hass) return; - await this._subscribeToEvents(); - const message = text.trim(); this._addMessage("user", message); @@ -608,15 +615,56 @@ class OpenClawChatCard extends HTMLElement { this._scrollToBottom(); try { - await this._hass.callService("openclaw", "send_message", { + let result; + const payload = { + type: "openclaw/send_message", message: message, source: source || undefined, session_id: this._config.session_id || undefined, - }); + }; + if (typeof this._hass.callWS === "function") { + result = await this._hass.callWS(payload); + } else { + result = await this._hass.connection.sendMessagePromise(payload); + } - setTimeout(() => { - this._syncHistoryFromBackend(1); - }, 1200); + const previousStorageIdentity = this._getStorageIdentity(); + if (result?.ha_user_id) { + this._backendHaUserId = result.ha_user_id; + } + if (result?.agent_id) { + this._activeAgentId = result.agent_id; + } + if (this._getStorageIdentity() !== previousStorageIdentity) { + this._resetTransientChatState(); + } + + const serverMessages = Array.isArray(result?.messages) ? result.messages : []; + const validMessages = serverMessages.filter( + (m) => m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string" + ); + if (validMessages.length) { + this._messages = validMessages; + } else { + const thinkingIdx = this._messages.findIndex((m) => m._thinking); + const assistantText = result?.message || "Response received, but no readable message content was found."; + if (thinkingIdx >= 0) { + this._messages[thinkingIdx] = { + role: "assistant", + content: assistantText, + timestamp: result?.timestamp || new Date().toISOString(), + }; + } else { + this._addMessage("assistant", assistantText); + } + } + + this._isProcessing = false; + this._pendingResponses = 0; + this._clearThinkingTimer(); + this._persistMessages(); + this._render(); + this._scrollToBottom(); } catch (err) { console.error("OpenClaw: Failed to send message:", err); // Replace thinking with error