From b23fb6ccaa31086d7e61b2a5077e8d7d93a3bd1e Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Mon, 18 May 2026 10:29:26 -0700 Subject: [PATCH 1/2] feat(runtime): route goal through adapter seam --- CHANGELOG.md | 1 + api/routes.py | 38 +++++++++-- api/runtime_adapter.py | 39 ++++++++++- docs/rfcs/hermes-run-adapter-contract.md | 22 +++--- tests/test_goal_command_webui.py | 43 ++++++++++++ tests/test_runtime_adapter_seam.py | 87 +++++++++++++++++++++--- 6 files changed, 203 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5fa89b5..4ea344cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### Documentation +- **PR #2544** by @Michaelyklam (refs #1925) — Implement the first Slice 3c RuntimeAdapter control routing. `RuntimeAdapter` / `LegacyJournalRuntimeAdapter` now expose `queue_message(...)` and `update_goal(...)` as protocol-translator delegates, and the `/api/goal` route uses `update_goal(...)` only when `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled while preserving the legacy-direct response shape. The change keeps `/queue`'s existing browser-side drain semantics and goal post-turn evaluation in the current agent loop; no runner/sidecar, WebUI-owned queue, goal scheduler, cached-agent table, or execution-survives-restart claim is introduced. - **PR #2511** by @franksong2702 (refs #2502 / #2503) — Update the `docs/ui-ux/` demo appearance controls to initialize as `class="dark" data-skin="slate"` instead of the deprecated `data-theme`-only buttons and legacy theme names. Brings the demo pages in line with the live Theme + Skin contract referenced from the new `docs/CONTRACTS.md` so contributors following the contract-index path don't land on stale demos. - **PR #2509** by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC after the Slice 3b approval/clarify seam shipped in v0.51.89. The RFC now marks Slice 3b as shipped and defines the next Slice 3c queue/continue + goal control gate: route those controls through `RuntimeAdapter.queue_message(...)` / `update_goal(...)` only after pinning stable response contracts, bounded unavailable-control behavior, replayable lifecycle/status evidence, ordering/idempotency expectations, and explicit non-goals for runner/sidecar ownership or a WebUI-owned queue/goal scheduler. Docs + adapter-seam regression test only — no runtime/control routing changes in this PR. diff --git a/api/routes.py b/api/routes.py index a56a2b47..f123b76b 100644 --- a/api/routes.py +++ b/api/routes.py @@ -7798,6 +7798,18 @@ def _start_chat_stream_for_session( return response +def _runtime_adapter_goal_action(goal_args: str) -> str: + """Return the bounded RuntimeAdapter goal action for WebUI /goal args.""" + action = str(goal_args or "").strip().lower() + if not action or action == "status": + return "status" + if action in ("pause", "resume"): + return action + if action in ("clear", "stop", "done"): + return "clear" + return "set" + + def _handle_goal_command(handler, body): """Handle WebUI /goal command controls and optional kickoff stream.""" try: @@ -7870,12 +7882,26 @@ def _handle_goal_command(handler, body): ) previous_goal_state = goal_state_snapshot(s.session_id, profile_home=profile_home) - payload = goal_command_payload( - s.session_id, - goal_args, - stream_running=stream_running, - profile_home=profile_home, - ) + from api.runtime_adapter import LegacyJournalRuntimeAdapter, runtime_adapter_enabled + + def _legacy_goal_update(session_id: str, _action: str, text: str) -> dict: + return goal_command_payload( + session_id, + text, + stream_running=stream_running, + profile_home=profile_home, + ) + + 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_args, + ) + payload = dict(control_result.payload) + else: + payload = _legacy_goal_update(s.session_id, _runtime_adapter_goal_action(goal_args), 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 f9140593..17567ac8 100644 --- a/api/runtime_adapter.py +++ b/api/runtime_adapter.py @@ -12,7 +12,7 @@ from __future__ import annotations from dataclasses import dataclass, field import os from pathlib import Path -from typing import Any, Callable, Iterable, Protocol +from typing import Any, Callable, Iterable, Literal, Protocol _RUNTIME_ADAPTER_ENV = "HERMES_WEBUI_RUNTIME_ADAPTER" _RUNTIME_ADAPTER_DIRECT = "legacy-direct" @@ -72,6 +72,7 @@ class ControlResult: status: str = "accepted" event_id: str | None = None safe_message: str | None = None + payload: dict[str, Any] = field(default_factory=dict) class RuntimeAdapter(Protocol): @@ -81,6 +82,13 @@ class RuntimeAdapter(Protocol): def cancel_run(self, run_id: str) -> ControlResult: ... def respond_approval(self, run_id: str, approval_id: str, choice: str) -> ControlResult: ... def respond_clarify(self, run_id: str, clarify_id: str, response: str) -> ControlResult: ... + def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> ControlResult: ... + def update_goal( + self, + session_id: str, + action: Literal["set", "pause", "resume", "clear", "status", "edit"], + text: str = "", + ) -> ControlResult: ... def runtime_adapter_mode(environ: dict[str, str] | None = None) -> str: @@ -107,6 +115,16 @@ def _cursor_to_after_seq(cursor: str | None) -> int | None: def _active_control_result(value: Any) -> ControlResult: + if isinstance(value, ControlResult): + return value + if isinstance(value, dict): + accepted = bool(value.get("ok", True)) + return ControlResult( + accepted=accepted, + status=str(value.get("status") or value.get("action") or ("accepted" if accepted else "not-active")), + safe_message=value.get("message") if not accepted else None, + payload=dict(value), + ) accepted = bool(value) return ControlResult( accepted=accepted, @@ -129,6 +147,8 @@ class LegacyJournalRuntimeAdapter: cancel_delegate: Callable[[str], Any] | None = None, approval_delegate: Callable[[str, str, str], Any] | None = None, clarify_delegate: Callable[[str, str, str], Any] | None = None, + queue_delegate: Callable[[str, str, str], Any] | None = None, + goal_delegate: Callable[[str, str, str], Any] | None = None, live_stream_lookup: Callable[[str], bool] | None = None, session_dir: Path | None = None, ): @@ -136,6 +156,8 @@ class LegacyJournalRuntimeAdapter: self._cancel_delegate = cancel_delegate self._approval_delegate = approval_delegate self._clarify_delegate = clarify_delegate + self._queue_delegate = queue_delegate + self._goal_delegate = goal_delegate self._live_stream_lookup = live_stream_lookup or (lambda _run_id: False) self._session_dir = Path(session_dir) if session_dir is not None else None @@ -221,3 +243,18 @@ class LegacyJournalRuntimeAdapter: if self._clarify_delegate is None: return ControlResult(False, status="unsupported", safe_message="Clarify is delegated to the legacy path.") return _active_control_result(self._clarify_delegate(run_id, clarify_id, response)) + + def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> ControlResult: + if self._queue_delegate is None: + return ControlResult(False, status="unsupported", safe_message="Queue is delegated to the legacy path.") + return _active_control_result(self._queue_delegate(run_id, message, mode)) + + def update_goal( + self, + session_id: str, + action: Literal["set", "pause", "resume", "clear", "status", "edit"], + text: str = "", + ) -> ControlResult: + if self._goal_delegate is None: + return ControlResult(False, status="unsupported", safe_message="Goal is delegated to the legacy path.") + return _active_control_result(self._goal_delegate(session_id, action, text)) diff --git a/docs/rfcs/hermes-run-adapter-contract.md b/docs/rfcs/hermes-run-adapter-contract.md index cb9cf63e..63cb8cf7 100644 --- a/docs/rfcs/hermes-run-adapter-contract.md +++ b/docs/rfcs/hermes-run-adapter-contract.md @@ -82,11 +82,14 @@ adapter-seam work: - #2479 shipped the first Slice 3a implementation in v0.51.86, routing Stop Generation through `RuntimeAdapter.cancel_run(...)` only when `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled. +- #2487 shipped the Slice 3b approval/clarify gate, and #2496 shipped approval / + clarify response routing through the adapter seam in v0.51.89. +- #2509 shipped the Slice 3c queue/continue + goal gate in v0.51.90. -The next gate is not the runner/sidecar yet. It is the next control migration -gate: approval and clarify must be specified together before implementation, -because both are callback-backed, user-mediated pauses in the active agent loop -and have the same state-lifetime/replay hazards. +The next gate is not the runner/sidecar yet. It is the Slice 3c implementation: +add the queue/goal adapter methods and route accepted legacy control paths +through them without moving goal evaluation, continuation scheduling, or +execution ownership out of the existing agent loop. ## Goals @@ -364,7 +367,7 @@ Required data classes / payload fields: | `RunStartResult` | `run_id`, `session_id`, `stream_id`, `status`, `started_at`, `cursor`, `active_controls` | `stream_id` may remain the legacy stream id during Slice 2. | | `RunStatus` | `run_id`, `session_id`, `status`, `last_event_id`, `terminal_state`, `active_controls`, `pending_approval_id`, `pending_clarify_id` | Backed by live legacy state plus journal/session metadata. | | `RunEventStream` | ordered events matching Artifact 1, resumable from cursor | Can be implemented by existing SSE + journal replay at first. | -| `ControlResult` | `accepted`, `status`, `event_id`, `safe_message` | Controls may still call existing handlers in Slice 2. | +| `ControlResult` | `accepted`, `status`, `event_id`, `safe_message`, optional internal `payload` | Controls may still call existing handlers in Slice 2. Public HTTP responses must not leak adapter-only fields unless a later RFC expands them. | The interface is intentionally narrower than a runner. It does not own `AIAgent`, tool execution, callback queues, cancellation flags, approval callbacks, or @@ -438,13 +441,14 @@ execution-survives-WebUI-restart gate remains deferred to Slice 4. ### Slice 3: Control migration Status as of 2026-05-18: Slice 3a cancel routing shipped in v0.51.86 via #2479, -and Slice 3b approval/clarify routing shipped in v0.51.89 via #2496 / #2507. +Slice 3b approval/clarify routing shipped in v0.51.89 via #2496 / #2507, and +the Slice 3c queue/continue + goal gate shipped in v0.51.90 via #2509. Cancel was the smallest control-plane migration because it already had one clear browser affordance, one active-run target, and an existing legacy handler to delegate to. Approval and clarify then proved the same protocol-translator shape -for user-mediated callback controls. Queue/continue and goal remain intentionally -held behind the next gate because they can change run lifecycle semantics rather -than just resolve an already-pending control. +for user-mediated callback controls. Queue/continue and goal are the final +pre-runner control migration because they can change run lifecycle semantics +rather than just resolve an already-pending control. Scope: diff --git a/tests/test_goal_command_webui.py b/tests/test_goal_command_webui.py index 4d27de0a..93fd5505 100644 --- a/tests/test_goal_command_webui.py +++ b/tests/test_goal_command_webui.py @@ -230,6 +230,49 @@ def test_goal_endpoint_sets_goal_and_starts_kickoff_stream(monkeypatch, tmp_path assert started[0]["model_provider"] == "openai-codex" +def test_goal_endpoint_preserves_response_shape_under_runtime_adapter_flag(monkeypatch, tmp_path): + """The Slice 3c adapter path delegates /goal without adding adapter-only fields.""" + from api import goals as webui_goals + from api import routes + + class FakeState: + goal = "ship the feature" + status = "active" + turns_used = 1 + 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() + + 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, "j", lambda handler, payload, status=200, **kwargs: {"status": status, "payload": payload}) + + result = routes._handle_goal_command(object(), {"session_id": "sid-goal-route", "args": "status"}) + + assert result["status"] == 200 + assert result["payload"]["action"] == "status" + assert result["payload"]["message_key"] == "goal_status_active" + assert "run_id" not in result["payload"] + assert "active_controls" not in result["payload"] + + 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 5750c9a6..cb43b790 100644 --- a/tests/test_runtime_adapter_seam.py +++ b/tests/test_runtime_adapter_seam.py @@ -12,6 +12,8 @@ def test_runtime_adapter_interface_and_legacy_journal_methods_exist(): "cancel_run", "respond_approval", "respond_clarify", + "queue_message", + "update_goal", ) for name in required: assert hasattr(runtime.RuntimeAdapter, name) @@ -24,17 +26,6 @@ def test_runtime_adapter_interface_and_legacy_journal_methods_exist(): assert runtime.runtime_adapter_mode({"HERMES_WEBUI_RUNTIME_ADAPTER": "sidecar"}) == "legacy-direct" -def test_queue_goal_adapter_methods_remain_docs_only_until_slice3c_implementation(): - # REMOVAL REQUIRED: delete this test as part of Slice 3c implementation (refs #1925), - # at the point queue_message / update_goal land on RuntimeAdapter / LegacyJournalRuntimeAdapter. - # See docs/rfcs/hermes-run-adapter-contract.md Slice 3c acceptance + non-goals. - runtime = importlib.import_module("api.runtime_adapter") - - for name in ("queue_message", "queue_input", "update_goal"): - assert not hasattr(runtime.RuntimeAdapter, name) - assert not hasattr(runtime.LegacyJournalRuntimeAdapter, name) - - def test_legacy_journal_adapter_start_run_delegates_without_owning_runtime_state(): runtime = importlib.import_module("api.runtime_adapter") calls = [] @@ -120,6 +111,30 @@ def test_legacy_journal_adapter_controls_delegate_to_existing_handlers(): ] +def test_legacy_journal_adapter_queue_and_goal_delegate_without_owning_runtime_state(): + runtime = importlib.import_module("api.runtime_adapter") + calls = [] + adapter = runtime.LegacyJournalRuntimeAdapter( + queue_delegate=lambda run_id, message, mode: calls.append(("queue", run_id, message, mode)) or True, + goal_delegate=lambda session_id, action, text: calls.append(("goal", session_id, action, text)) or { + "ok": True, + "action": action, + "message": "Goal updated.", + }, + ) + + queued = adapter.queue_message("r1", "follow up", mode="queue") + goal = adapter.update_goal("s1", "set", "finish the task") + + assert queued.accepted is True + assert goal.accepted is True + assert goal.payload["action"] == "set" + assert calls == [ + ("queue", "r1", "follow up", "queue"), + ("goal", "s1", "set", "finish the task"), + ] + + def test_legacy_journal_adapter_cancel_returns_bounded_not_active_status(): runtime = importlib.import_module("api.runtime_adapter") calls = [] @@ -156,6 +171,29 @@ def test_legacy_journal_adapter_approval_and_clarify_return_bounded_not_active_s assert clarify.status == "not-active" +def test_legacy_journal_adapter_queue_and_goal_return_bounded_statuses(): + runtime = importlib.import_module("api.runtime_adapter") + adapter = runtime.LegacyJournalRuntimeAdapter( + queue_delegate=lambda run_id, message, mode: False, + goal_delegate=lambda session_id, action, text: { + "ok": False, + "action": action, + "error": "agent_running", + "message": "Agent is running.", + }, + ) + + queued = adapter.queue_message("already-finished-run", "follow up") + goal = adapter.update_goal("s1", "set", "new goal") + + assert queued.accepted is False + assert queued.status == "not-active" + assert goal.accepted is False + assert goal.status == "set" + assert goal.safe_message == "Agent is running." + assert goal.payload["error"] == "agent_running" + + def test_chat_cancel_route_uses_adapter_only_when_flag_enabled(): routes = importlib.import_module("api.routes") src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8") @@ -191,6 +229,33 @@ def test_approval_and_clarify_routes_use_adapter_only_when_flag_enabled(): assert "HERMES_WEBUI_RUNTIME_ADAPTER" not in clarify_body +def test_goal_route_uses_adapter_only_when_flag_enabled(): + routes = importlib.import_module("api.routes") + src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8") + goal_idx = src.index("def _handle_goal_command") + goal_body = src[goal_idx:src.index("def _handle_chat_start", goal_idx)] + + assert "runtime_adapter_enabled()" in goal_body + assert "LegacyJournalRuntimeAdapter(goal_delegate=_legacy_goal_update)" in goal_body + assert "adapter.update_goal(" 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 + + +def test_goal_adapter_action_is_bounded_to_slice3c_actions(): + routes = importlib.import_module("api.routes") + + assert routes._runtime_adapter_goal_action("") == "status" + assert routes._runtime_adapter_goal_action("status") == "status" + assert routes._runtime_adapter_goal_action("pause") == "pause" + assert routes._runtime_adapter_goal_action("resume") == "resume" + assert routes._runtime_adapter_goal_action("clear") == "clear" + assert routes._runtime_adapter_goal_action("stop") == "clear" + assert routes._runtime_adapter_goal_action("done") == "clear" + assert routes._runtime_adapter_goal_action("ship #1925") == "set" + + def test_approval_respond_does_not_fallback_to_oldest_when_explicit_id_is_stale(): routes = importlib.import_module("api.routes") src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8") From 6a68bab114a6571cf398dd5e029628835cbcbc92 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Mon, 18 May 2026 12:10:17 -0700 Subject: [PATCH 2/2] 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