From 6a68bab114a6571cf398dd5e029628835cbcbc92 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Mon, 18 May 2026 12:10:17 -0700 Subject: [PATCH] fix(runtime): clarify goal adapter seam semantics --- api/routes.py | 8 +- api/runtime_adapter.py | 7 ++ docs/rfcs/hermes-run-adapter-contract.md | 10 ++- tests/test_goal_command_webui.py | 95 ++++++++++++++++++++++++ tests/test_runtime_adapter_seam.py | 2 + 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/api/routes.py b/api/routes.py index f123b76b..c7ea6c2d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -7892,16 +7892,20 @@ def _handle_goal_command(handler, body): profile_home=profile_home, ) + goal_adapter_action = _runtime_adapter_goal_action(goal_args) if runtime_adapter_enabled(): adapter = LegacyJournalRuntimeAdapter(goal_delegate=_legacy_goal_update) control_result = adapter.update_goal( s.session_id, - _runtime_adapter_goal_action(goal_args), + goal_adapter_action, goal_args, ) + # Slice 3c keeps the adapter as a structural seam only. Preserve the + # public /api/goal response by passing through the legacy payload rather + # than deriving HTTP behavior from ControlResult.accepted/status. payload = dict(control_result.payload) else: - payload = _legacy_goal_update(s.session_id, _runtime_adapter_goal_action(goal_args), goal_args) + payload = _legacy_goal_update(s.session_id, goal_adapter_action, goal_args) if not payload.get("ok", True): status = 409 if payload.get("error") == "agent_running" else 400 return j(handler, payload, status=status) diff --git a/api/runtime_adapter.py b/api/runtime_adapter.py index 17567ac8..034d1c2d 100644 --- a/api/runtime_adapter.py +++ b/api/runtime_adapter.py @@ -115,6 +115,13 @@ def _cursor_to_after_seq(cursor: str | None) -> int | None: def _active_control_result(value: Any) -> ControlResult: + """Normalize legacy delegate responses without changing their payloads. + + ``status`` is an adapter-level summary used by current control tests and + future runtime backends. For legacy goal payloads it may mirror the goal + action (``set`` / ``pause`` / ``status``), while public route behavior keeps + using the payload itself to preserve existing HTTP response shapes. + """ if isinstance(value, ControlResult): return value if isinstance(value, dict): diff --git a/docs/rfcs/hermes-run-adapter-contract.md b/docs/rfcs/hermes-run-adapter-contract.md index 63cb8cf7..1431a123 100644 --- a/docs/rfcs/hermes-run-adapter-contract.md +++ b/docs/rfcs/hermes-run-adapter-contract.md @@ -357,7 +357,15 @@ class RuntimeAdapter: `queue_message` is named for the legacy `/api/session/queue` payload: it accepts follow-up chat text rather than arbitrary runtime input. The method name does not require the HTTP route to change; it documents the adapter-level control -semantics that a later Slice 3c implementation should preserve. +semantics that a later Slice 3c implementation should preserve. The method enters +the protocol before route wiring so queue/continue can land as a separate, small +control-routing follow-up instead of being coupled to goal routing. + +For `update_goal`, the `action` argument is the bounded adapter capability label. +During the legacy-journal slice, the legacy goal parser still receives the full +`text` payload and remains authoritative for details such as the body of +`set `. Future slices must not route goal semantics from `action` +alone; doing so would drop the goal body and change `/api/goal` behavior. Required data classes / payload fields: diff --git a/tests/test_goal_command_webui.py b/tests/test_goal_command_webui.py index 93fd5505..8065dc95 100644 --- a/tests/test_goal_command_webui.py +++ b/tests/test_goal_command_webui.py @@ -273,6 +273,101 @@ def test_goal_endpoint_preserves_response_shape_under_runtime_adapter_flag(monke assert "active_controls" not in result["payload"] +def test_goal_endpoint_adapter_keeps_full_set_text_and_legacy_payload_status(monkeypatch, tmp_path): + """The adapter action label must not replace legacy parsing of full goal text.""" + from api import goals as webui_goals + from api import routes + + set_calls = [] + + class FakeState: + goal = "" + status = "active" + turns_used = 0 + max_turns = 20 + last_verdict = None + last_reason = None + paused_reason = None + + class FakeGoalManager: + def __init__(self, session_id, default_max_turns=20): + self.state = FakeState() + + def set(self, text): + set_calls.append(text) + self.state.goal = text + return self.state + + class FakeSession: + session_id = "sid-goal-route" + profile = "default" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "openai-codex" + messages = [] + context_messages = [] + pending_user_message = None + active_stream_id = None + + monkeypatch.setenv("HERMES_WEBUI_RUNTIME_ADAPTER", "legacy-journal") + monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) + monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession()) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path) + monkeypatch.setattr( + routes, + "_resolve_compatible_session_model_state", + lambda model, provider: (model, provider, False), + ) + monkeypatch.setattr( + routes, + "_start_chat_stream_for_session", + lambda session, **kwargs: {"stream_id": "goal-stream", "session_id": session.session_id}, + ) + monkeypatch.setattr(routes, "j", lambda handler, payload, status=200, **kwargs: {"status": status, "payload": payload}) + + result = routes._handle_goal_command(object(), {"session_id": "sid-goal-route", "args": "set foo"}) + + assert result["status"] == 200 + assert result["payload"]["action"] == "set" + assert result["payload"]["kickoff_prompt"] == "set foo" + assert set_calls == ["set foo"] + + +def test_goal_endpoint_adapter_error_payload_still_controls_http_status(monkeypatch, tmp_path): + """The /goal route preserves legacy error/status handling under the adapter flag.""" + from api import goals as webui_goals + from api import routes + + class FakeGoalManager: + state = None + + def __init__(self, session_id, default_max_turns=20): + pass + + class FakeSession: + session_id = "sid-goal-route" + profile = "default" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "openai-codex" + messages = [] + context_messages = [] + pending_user_message = None + active_stream_id = "running-stream" + + monkeypatch.setenv("HERMES_WEBUI_RUNTIME_ADAPTER", "legacy-journal") + monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) + monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession()) + monkeypatch.setitem(routes.STREAMS, "running-stream", {"queue": object()}) + monkeypatch.setattr(routes, "j", lambda handler, payload, status=200, **kwargs: {"status": status, "payload": payload}) + + result = routes._handle_goal_command(object(), {"session_id": "sid-goal-route", "args": "ship it"}) + + assert result["status"] == 409 + assert result["payload"]["ok"] is False + assert result["payload"]["error"] == "agent_running" + + def test_routes_register_goal_endpoint_and_kickoff_stream(): assert 'if parsed.path == "/api/goal"' in ROUTES_PY assert "return _handle_goal_command(handler, body)" in ROUTES_PY diff --git a/tests/test_runtime_adapter_seam.py b/tests/test_runtime_adapter_seam.py index cb43b790..8c8ad1e5 100644 --- a/tests/test_runtime_adapter_seam.py +++ b/tests/test_runtime_adapter_seam.py @@ -237,7 +237,9 @@ def test_goal_route_uses_adapter_only_when_flag_enabled(): assert "runtime_adapter_enabled()" in goal_body assert "LegacyJournalRuntimeAdapter(goal_delegate=_legacy_goal_update)" in goal_body + assert "goal_adapter_action = _runtime_adapter_goal_action(goal_args)" in goal_body assert "adapter.update_goal(" in goal_body + assert "goal_adapter_action," in goal_body assert "payload = dict(control_result.payload)" in goal_body assert "else:\n payload = _legacy_goal_update" in goal_body assert "HERMES_WEBUI_RUNTIME_ADAPTER" not in goal_body