diff --git a/loopx/chat.py b/loopx/chat.py index a322f9cb46..0221b7bc51 100644 --- a/loopx/chat.py +++ b/loopx/chat.py @@ -7,6 +7,9 @@ from typing import Any, Iterable, Mapping from .todos import add_goal_todo +from .control_plane.work_items.governed_transition_proposal import ( + STEWARD_TEAM_PLAN_PREVIEW_KIND, +) CHAT_AGENT_RESPONSE_SCHEMA_VERSION = "loopx_chat_agent_response_v0" @@ -178,12 +181,29 @@ def _compact_line(value: Any, *, limit: int) -> str: return text[:limit].strip() -def _normalize_proposals(value: Any, *, protected_paths: Iterable[Path | str]) -> list[dict[str, str]]: - proposals: list[dict[str, str]] = [] +def _normalize_proposals( + value: Any, + *, + protected_paths: Iterable[Path | str], + team_plan_context: Mapping[str, Any] | None = None, +) -> list[dict[str, Any]]: + proposals: list[dict[str, Any]] = [] if not isinstance(value, list): return proposals for raw in value[:5]: - if not isinstance(raw, dict) or raw.get("kind") != "todo": + if not isinstance(raw, dict): + continue + if raw.get("kind") == STEWARD_TEAM_PLAN_PREVIEW_KIND: + # A team plan is admitted here or not at all: without the host facts + # that say which Agents and action kinds exist, a preview cannot be + # validated, so it is never surfaced half-checked. + preview = _validated_team_plan_preview(raw, team_plan_context) + if preview is not None: + proposals.append( + {"kind": STEWARD_TEAM_PLAN_PREVIEW_KIND, "preview": preview} + ) + continue + if raw.get("kind") != "todo": continue text = _compact_line(redact_local_paths(str(raw.get("text") or ""), protected_paths=protected_paths), limit=400) if not text: @@ -206,6 +226,29 @@ def _normalize_proposals(value: Any, *, protected_paths: Iterable[Path | str]) - return proposals +def _validated_team_plan_preview( + raw: Mapping[str, Any], context: Mapping[str, Any] | None +) -> dict[str, Any] | None: + """Return the validated preview, or ``None`` when it may not be surfaced.""" + + if not isinstance(context, Mapping): + return None + from .control_plane.work_items.governed_transition_proposal import ( + validate_steward_team_plan_preview, + ) + + try: + return validate_steward_team_plan_preview( + raw, + registered_agent_ids=list(context.get("registered_agent_ids") or []), + supported_action_kinds=list(context.get("supported_action_kinds") or []), + ) + except ValueError: + # A malformed preview is dropped exactly like any other proposal this + # normalizer cannot accept; the answer text still reaches the owner. + return None + + def _normalize_protected_action( value: Any, *, @@ -272,6 +315,7 @@ def normalize_agent_response( payload: Mapping[str, Any], *, protected_paths: Iterable[Path | str] = (), + team_plan_context: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Normalize one structured provider response to the public Chat contract.""" @@ -289,6 +333,7 @@ def normalize_agent_response( "proposals": _normalize_proposals( payload.get("proposals"), protected_paths=protected, + team_plan_context=team_plan_context, ), "protected_action": _normalize_protected_action( payload.get("protected_action"), diff --git a/tests/test_manager_channel_binding.py b/tests/test_manager_channel_binding.py index 171b777c49..1ec61900b6 100644 --- a/tests/test_manager_channel_binding.py +++ b/tests/test_manager_channel_binding.py @@ -836,8 +836,8 @@ def test_every_production_steward_caller_passes_the_machine_defaults() -> None: assert len(call_sites) >= 14, call_sites assert {path for path, _line, _name, _ok in call_sites} >= { "loopx/chat_manager.py", + "loopx/chat_manager_context.py", "loopx/chat_runtime.py", - "loopx/chat_server.py", "loopx/extensions/lark/goal_topic_runtime.py", } undocumented = [ diff --git a/tests/test_steward_team_plan_preview.py b/tests/test_steward_team_plan_preview.py index 0d1b6101a9..0953c38514 100644 --- a/tests/test_steward_team_plan_preview.py +++ b/tests/test_steward_team_plan_preview.py @@ -119,3 +119,51 @@ def test_the_preview_kind_has_no_materializer() -> None: """Nothing may apply the preview while only the preview slice exists.""" assert STEWARD_TEAM_PLAN_PREVIEW_KIND not in _SETTLEMENT_PHASE_BY_PROPOSAL_KIND + + +def test_the_chat_normalizer_admits_a_preview_only_with_host_facts() -> None: + """The production caller: one malformed or unproven preview never surfaces.""" + + from loopx.chat import normalize_agent_response + + envelope = { + "message": "Here is the plan", + "proposals": [_plan()], + "context_handoff": None, + "protected_action": None, + "gate": None, + } + context = { + "registered_agent_ids": ["agent-alpha"], + "supported_action_kinds": ["implement"], + } + + surfaced = normalize_agent_response(envelope, team_plan_context=context) + proposals = surfaced["proposals"] + assert [item["kind"] for item in proposals] == [ + "steward_team_plan_preview" + ] + assert proposals[0]["preview"]["applies"] is False + assert proposals[0]["preview"]["lanes"][0]["staffing"] == "ready" + + # Without the host facts the preview cannot be validated, so it is not + # surfaced at all rather than admitted half-checked. + assert normalize_agent_response(envelope)["proposals"] == [] + + # A malformed preview is dropped like any other proposal the normalizer + # cannot accept, and the owner's answer text still arrives. + malformed = {**envelope, "proposals": [_plan(kind="not_a_plan")]} + dropped = normalize_agent_response(malformed, team_plan_context=context) + assert dropped["proposals"] == [] + assert dropped["message"] == "Here is the plan" + + # The plain Todo proposals keep their existing behaviour. + todo = { + **envelope, + "proposals": [ + {"kind": "todo", "text": "Do one thing", "priority": "P2", "rationale": "why"} + ], + } + assert normalize_agent_response(todo)["proposals"] == [ + {"kind": "todo", "text": "Do one thing", "priority": "P2", "rationale": "why"} + ]