Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<id>:<conversation>`), so the agent can tell who is speaking while each conversation keeps its own session.

### Voice provider usage

Expand Down
27 changes: 11 additions & 16 deletions custom_components/openclaw/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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."""
Expand Down
26 changes: 26 additions & 0 deletions custom_components/openclaw/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
54 changes: 54 additions & 0 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down