diff --git a/loopx/extensions/lark/goal_channel_contracts.py b/loopx/extensions/lark/goal_channel_contracts.py index 989b2df6bc..15344b5633 100644 --- a/loopx/extensions/lark/goal_channel_contracts.py +++ b/loopx/extensions/lark/goal_channel_contracts.py @@ -216,40 +216,50 @@ def binding_for_goal( agent_id: str | None = None, connection_id: str | None = None, ) -> dict[str, Any] | None: - candidates = bindings_for_goal( - payload, - goal_id, - provider_target=provider_target, - ) + # Select one raw connection before resolving its provider target. A target + # belongs to one connection; applying it to every sibling lets an unrelated + # Agent's target invalidate the requested Agent's otherwise valid binding. + candidates = bindings_for_goal(payload, goal_id) + selected: dict[str, Any] | None = None if connection_id: - return next( + selected = next( (item for item in candidates if item.get("connection_id") == connection_id), None, ) - if agent_id is not None: - return next( + elif agent_id is not None: + selected = next( (item for item in candidates if item.get("agent_id") == agent_id), None, ) - bindings = payload.get("bindings") - stored = bindings.get(goal_id) if isinstance(bindings, Mapping) else None - default_id = ( - str(stored.get("default_connection_id") or "") - if isinstance(stored, Mapping) - else "" - ) - if default_id: - selected = next( - (item for item in candidates if item.get("connection_id") == default_id), - None, + else: + bindings = payload.get("bindings") + stored = bindings.get(goal_id) if isinstance(bindings, Mapping) else None + default_id = ( + str(stored.get("default_connection_id") or "") + if isinstance(stored, Mapping) + else "" ) - if selected is not None: - return selected - # Keep the invalid-default fallback aligned with the writer in - # _without_goal_topic_connection, which promotes min(connection_id). - if not candidates: - return None - return min(candidates, key=lambda item: str(item.get("connection_id") or "")) + if default_id: + selected = next( + ( + item + for item in candidates + if item.get("connection_id") == default_id + ), + None, + ) + if selected is None and candidates: + # Keep the invalid-default fallback aligned with the writer in + # _without_goal_topic_connection, which promotes min(connection_id). + selected = min( + candidates, + key=lambda item: str(item.get("connection_id") or ""), + ) + return ( + _resolve_goal_binding(selected, provider_target=provider_target) + if selected is not None + else None + ) def human_gate_auto_notify_enabled(binding: Mapping[str, Any] | None) -> bool: diff --git a/tests/extensions/test_lark_goal_channel_binding_isolation.py b/tests/extensions/test_lark_goal_channel_binding_isolation.py new file mode 100644 index 0000000000..d41f5a05b9 --- /dev/null +++ b/tests/extensions/test_lark_goal_channel_binding_isolation.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import pytest + +from loopx.extensions.lark.goal_channel_contracts import binding_for_goal + + +def _connection(agent_id: str, target_ref: str) -> dict[str, object]: + return { + "goal_id": "goal-alpha", + "agent_id": agent_id, + "provider": "lark", + "enabled": True, + "target_ref": target_ref, + "channel": {}, + } + + +def _target(name: str, chat_id: str) -> dict[str, object]: + return { + "name": name, + "provider": "lark", + "channel": {"chat_id": chat_id}, + "identity": { + "mode": "project_bot", + "sender_identity": "bot", + "sender_profile": f"{name}-profile", + }, + } + + +def test_binding_resolution_isolates_selected_agent_from_sibling_target() -> None: + payload = { + "bindings": { + "goal-alpha": { + "schema_version": "loopx_goal_channel_connection_set_v0", + "default_connection_id": "connection-alpha", + "connections": { + "connection-alpha": _connection("agent-alpha", "target-alpha"), + "connection-beta": _connection("agent-beta", "target-beta"), + }, + } + } + } + + alpha = binding_for_goal( + payload, + "goal-alpha", + connection_id="connection-alpha", + provider_target=_target("target-alpha", "oc_alpha"), + ) + + assert alpha is not None + assert alpha["agent_id"] == "agent-alpha" + assert alpha["channel"] == {"chat_id": "oc_alpha"} + with pytest.raises(ValueError, match="does not match target_ref"): + binding_for_goal( + payload, + "goal-alpha", + connection_id="connection-beta", + provider_target=_target("target-alpha", "oc_alpha"), + )