diff --git a/README.md b/README.md index 34f21f2..d1a3b13 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ integration: - **Active Model** select entity controls the model override used for chat-card and Assist requests. - If no model is selected, the gateway default is used. - Assist conversation IDs are conservatively namespaced by agent ID so different agents do not accidentally share the same conversation context. +- When a signed-in Home Assistant user is known, the Assist session key carries it (`assist_user_:`), so the agent can tell who is speaking while each conversation keeps its own session. ### Voice provider usage diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 701134c..7e92abb 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -40,7 +40,11 @@ ) from .coordinator import OpenClawCoordinator from .exposure import apply_context_policy, build_exposed_entities_context -from .helpers import extract_text_recursive, scope_agent_session_id +from .helpers import ( + assist_base_session_id, + extract_text_recursive, + scope_agent_session_id, +) _LOGGER = logging.getLogger(__name__) @@ -247,22 +251,13 @@ def _resolve_conversation_id( return configured_session_id agent_suffix = self._normalize_optional_text(agent_id) - - if user_input.conversation_id: - return scope_agent_session_id(user_input.conversation_id, agent_suffix) - context = getattr(user_input, "context", None) - user_id = getattr(context, "user_id", None) - if user_id: - base_id = f"assist_user_{user_id}" - return scope_agent_session_id(base_id, agent_suffix) - - device_id = getattr(user_input, "device_id", None) - if device_id: - base_id = f"assist_device_{device_id}" - return scope_agent_session_id(base_id, agent_suffix) - - return scope_agent_session_id("assist_default", agent_suffix) + base_id = assist_base_session_id( + user_input.conversation_id, + getattr(context, "user_id", None), + getattr(user_input, "device_id", None), + ) + return scope_agent_session_id(base_id, agent_suffix) def _normalize_optional_text(self, value: Any) -> str | None: """Return a stripped string or None for blank values.""" diff --git a/custom_components/openclaw/helpers.py b/custom_components/openclaw/helpers.py index 0edd733..2108445 100644 --- a/custom_components/openclaw/helpers.py +++ b/custom_components/openclaw/helpers.py @@ -12,6 +12,32 @@ def scope_agent_session_id(base_id: str, agent_id: str | None) -> str: return f"agent:{agent_id}:{base_id}" +def assist_base_session_id( + conversation_id: str | None, + user_id: str | None, + device_id: str | None, +) -> str: + """Pick the unscoped Assist session id without dropping the signed-in user. + + The pipeline sends a conversation id on every turn, which used to hide the user. + """ + if user_id: + user_base = f"assist_user_{user_id}" + if not conversation_id: + return user_base + if ( + conversation_id.startswith(f"{user_base}:") + or f":{user_base}:" in conversation_id + ): + return conversation_id + return f"{user_base}:{conversation_id}" + if conversation_id: + return conversation_id + if device_id: + return f"assist_device_{device_id}" + return "assist_default" + + def resolve_active_model( preferred_model: str | None, available_models: list[str], diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f012724..a66efd7 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -36,6 +36,60 @@ def test_is_idempotent_for_an_already_scoped_key(self) -> None: ) +class AssistBaseSessionIdTests(unittest.TestCase): + """Keep the signed-in HA user in the key when HA sends a conversation id.""" + + def test_user_and_conversation_are_both_kept(self) -> None: + self.assertEqual( + helpers.assist_base_session_id("01m4hf34nxm64w74ab60v9pbrc", "abc", "dev1"), + "assist_user_abc:01m4hf34nxm64w74ab60v9pbrc", + ) + + def test_follow_up_turn_is_not_prefixed_twice(self) -> None: + self.assertEqual( + helpers.assist_base_session_id("assist_user_abc:01m4", "abc", None), + "assist_user_abc:01m4", + ) + self.assertEqual( + helpers.assist_base_session_id("agent:home:assist_user_abc:01m4", "abc", None), + "agent:home:assist_user_abc:01m4", + ) + + def test_user_without_conversation(self) -> None: + self.assertEqual( + helpers.assist_base_session_id(None, "abc", "dev1"), + "assist_user_abc", + ) + + def test_conversation_id_without_a_user(self) -> None: + self.assertEqual( + helpers.assist_base_session_id("01m4hf34nxm64w74ab60v9pbrc", None, "dev1"), + "01m4hf34nxm64w74ab60v9pbrc", + ) + + def test_device_without_user_or_conversation(self) -> None: + self.assertEqual( + helpers.assist_base_session_id(None, None, "dev1"), + "assist_device_dev1", + ) + + def test_default_when_nothing_is_known(self) -> None: + self.assertEqual( + helpers.assist_base_session_id(None, None, None), + "assist_default", + ) + + def test_scoped_follow_up_stays_stable(self) -> None: + first = helpers.scope_agent_session_id( + helpers.assist_base_session_id("01m4", "abc", None), "home" + ) + again = helpers.scope_agent_session_id( + helpers.assist_base_session_id(first, "abc", None), "home" + ) + self.assertEqual(first, "agent:home:assist_user_abc:01m4") + self.assertEqual(again, first) + + class ResolveActiveModelTests(unittest.TestCase): """Protect an explicit HA model choice across coordinator refreshes."""