Stage 323: PR #1866 — add WebUI /goal command support by @Michaelyklam

This commit is contained in:
nesquena-hermes
2026-05-08 17:12:01 +00:00
13 changed files with 1202 additions and 77 deletions
+1 -1
View File
@@ -168,7 +168,7 @@ Remaining gaps and forward work live in [Forward Work](#forward-work) below.
### Slash commands
- [x] Command registry + autocomplete dropdown
- [x] Built-ins: `/help`, `/clear`, `/model`, `/workspace`, `/new`, `/usage`, `/theme`, `/compact`, `/queue`, `/interrupt`, `/steer`, `/btw`, `/reasoning`, `/skills`, `/toolsets`
- [x] Built-ins: `/help`, `/clear`, `/model`, `/workspace`, `/new`, `/usage`, `/theme`, `/compact`, `/queue`, `/interrupt`, `/steer`, `/goal`, `/btw`, `/reasoning`, `/skills`, `/toolsets`
- [x] Transparent pass-through for unrecognized commands
### Security
+489
View File
@@ -0,0 +1,489 @@
"""WebUI bridge for Hermes persistent session goals."""
from __future__ import annotations
import copy
import logging
import time
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
try: # Exposed as a module attribute so tests can monkeypatch it directly.
from hermes_cli.goals import ( # type: ignore
CONTINUATION_PROMPT_TEMPLATE,
DEFAULT_MAX_TURNS,
GoalManager as _NativeGoalManager,
GoalState,
judge_goal,
)
except Exception: # pragma: no cover - depends on installed hermes-agent
CONTINUATION_PROMPT_TEMPLATE = "" # type: ignore
DEFAULT_MAX_TURNS = 20 # type: ignore
_NativeGoalManager = None # type: ignore
GoalState = None # type: ignore
judge_goal = None # type: ignore
GoalManager = _NativeGoalManager # type: ignore
_DB_CACHE: dict[str, Any] = {}
def _default_max_turns() -> int:
"""Return the configured /goal turn budget, defaulting to Hermes' 20 turns."""
try:
from api import config as _config
cfg = getattr(_config, "cfg", {}) or {}
goals_cfg = cfg.get("goals", {}) if isinstance(cfg, dict) else {}
if not isinstance(goals_cfg, dict):
return int(DEFAULT_MAX_TURNS or 20)
return max(1, int(goals_cfg.get("max_turns", DEFAULT_MAX_TURNS or 20) or 20))
except Exception:
return int(DEFAULT_MAX_TURNS or 20)
def _meta_key(session_id: str) -> str:
return f"goal:{session_id}"
def _profile_db(profile_home: str | Path):
"""Return a SessionDB pinned to *profile_home*, without reading HERMES_HOME.
The upstream Hermes GoalManager persists through hermes_cli.goals.load_goal(),
which resolves SessionDB from process-global HERMES_HOME. WebUI sessions are
profile-scoped and can run concurrently, so the WebUI bridge uses an explicit
state.db path whenever the caller provides the session's profile home.
"""
home = Path(profile_home).expanduser().resolve()
key = str(home)
cached = _DB_CACHE.get(key)
if cached is not None:
return cached
try:
from hermes_state import SessionDB # type: ignore
db = SessionDB(db_path=home / "state.db")
except Exception as exc: # pragma: no cover - import/env dependent
logger.debug("GoalManager profile DB unavailable for %s: %s", home, exc)
return None
_DB_CACHE[key] = db
return db
class _ProfileGoalManager:
"""Small WebUI-local GoalManager adapter with explicit profile persistence."""
def __init__(self, session_id: str, *, profile_home: str | Path, default_max_turns: int = 20):
if GoalState is None:
raise RuntimeError("Hermes goal state unavailable")
self.session_id = session_id
self.profile_home = Path(profile_home).expanduser().resolve()
self.default_max_turns = int(default_max_turns or DEFAULT_MAX_TURNS or 20)
self._state = self._load()
@property
def state(self):
return self._state
def _load(self):
db = _profile_db(self.profile_home)
if db is None or not self.session_id:
return None
try:
raw = db.get_meta(_meta_key(self.session_id))
except Exception as exc:
logger.debug("GoalManager profile get_meta failed: %s", exc)
return None
if not raw:
return None
try:
return GoalState.from_json(raw) # type: ignore[union-attr]
except Exception as exc:
logger.warning("GoalManager profile state parse failed for %s: %s", self.session_id, exc)
return None
def _save(self, state) -> None:
db = _profile_db(self.profile_home)
if db is None or not self.session_id or state is None:
return
try:
db.set_meta(_meta_key(self.session_id), state.to_json())
except Exception as exc:
logger.debug("GoalManager profile set_meta failed: %s", exc)
def is_active(self) -> bool:
return self._state is not None and self._state.status == "active"
def has_goal(self) -> bool:
return self._state is not None and self._state.status in ("active", "paused")
def status_line(self) -> str:
s = self._state
if s is None or s.status in ("cleared",):
return "No active goal. Set one with /goal <text>."
turns = f"{s.turns_used}/{s.max_turns} turns"
if s.status == "active":
return f"⊙ Goal (active, {turns}): {s.goal}"
if s.status == "paused":
extra = f"{s.paused_reason}" if s.paused_reason else ""
return f"⏸ Goal (paused, {turns}{extra}): {s.goal}"
if s.status == "done":
return f"✓ Goal done ({turns}): {s.goal}"
return f"Goal ({s.status}, {turns}): {s.goal}"
def set(self, goal: str, *, max_turns: Optional[int] = None):
goal = (goal or "").strip()
if not goal:
raise ValueError("goal text is empty")
state = GoalState( # type: ignore[operator]
goal=goal,
status="active",
turns_used=0,
max_turns=int(max_turns) if max_turns else self.default_max_turns,
created_at=time.time(),
last_turn_at=0.0,
)
self._state = state
self._save(state)
return state
def pause(self, reason: str = "user-paused"):
if not self._state:
return None
self._state.status = "paused"
self._state.paused_reason = reason
self._save(self._state)
return self._state
def resume(self, *, reset_budget: bool = True):
if not self._state:
return None
self._state.status = "active"
self._state.paused_reason = None
if reset_budget:
self._state.turns_used = 0
self._save(self._state)
return self._state
def clear(self) -> None:
if self._state is None:
return
self._state.status = "cleared"
self._save(self._state)
self._state = None
def evaluate_after_turn(self, last_response: str, *, user_initiated: bool = True) -> Dict[str, Any]:
state = self._state
if state is None or state.status != "active":
return {
"status": state.status if state else None,
"should_continue": False,
"continuation_prompt": None,
"verdict": "inactive",
"reason": "no active goal",
"message": "",
}
state.turns_used += 1
state.last_turn_at = time.time()
if judge_goal is None:
verdict, reason = "continue", "goal judge unavailable"
else:
verdict, reason = judge_goal(state.goal, str(last_response or ""))
state.last_verdict = verdict
state.last_reason = reason
if verdict == "done":
state.status = "done"
self._save(state)
return {
"status": "done",
"should_continue": False,
"continuation_prompt": None,
"verdict": "done",
"reason": reason,
"message": f"✓ Goal achieved: {reason}",
}
if state.turns_used >= state.max_turns:
state.status = "paused"
state.paused_reason = f"turn budget exhausted ({state.turns_used}/{state.max_turns})"
self._save(state)
return {
"status": "paused",
"should_continue": False,
"continuation_prompt": None,
"verdict": "continue",
"reason": reason,
"message": (
f"⏸ Goal paused — {state.turns_used}/{state.max_turns} turns used. "
"Use /goal resume to keep going, or /goal clear to stop."
),
}
self._save(state)
return {
"status": "active",
"should_continue": True,
"continuation_prompt": self.next_continuation_prompt(),
"verdict": "continue",
"reason": reason,
"message": f"↻ Continuing toward goal ({state.turns_used}/{state.max_turns}): {reason}",
}
def next_continuation_prompt(self) -> Optional[str]:
if not self._state or self._state.status != "active":
return None
return CONTINUATION_PROMPT_TEMPLATE.format(goal=self._state.goal)
def _manager(session_id: str, *, profile_home: str | Path | None = None):
if GoalManager is None:
return None
if profile_home and GoalManager is _NativeGoalManager and GoalState is not None:
try:
return _ProfileGoalManager(
session_id=session_id,
profile_home=profile_home,
default_max_turns=_default_max_turns(),
)
except Exception as exc:
logger.debug("Profile-scoped GoalManager unavailable: %s", exc)
return None
return GoalManager(session_id=session_id, default_max_turns=_default_max_turns())
def _state_payload(state: Any) -> Optional[Dict[str, Any]]:
if state is None:
return None
return {
"goal": getattr(state, "goal", "") or "",
"status": getattr(state, "status", "") or "",
"turns_used": int(getattr(state, "turns_used", 0) or 0),
"max_turns": int(getattr(state, "max_turns", 0) or 0),
"last_verdict": getattr(state, "last_verdict", None),
"last_reason": getattr(state, "last_reason", None),
"paused_reason": getattr(state, "paused_reason", None),
}
def _payload(
*,
ok: bool = True,
action: str,
message: str,
state: Any = None,
error: str | None = None,
kickoff_prompt: str | None = None,
decision: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
body: Dict[str, Any] = {
"ok": bool(ok),
"action": action,
"message": message,
"goal": _state_payload(state),
}
if error:
body["error"] = error
if kickoff_prompt:
body["kickoff_prompt"] = kickoff_prompt
if decision is not None:
body["decision"] = decision
return body
def goal_state_snapshot(session_id: str, *, profile_home: str | Path | None = None) -> Any:
"""Return a deep copy of current goal state for rollback before kickoff."""
mgr = _manager(str(session_id or ""), profile_home=profile_home)
if mgr is None:
return None
return copy.deepcopy(getattr(mgr, "state", None))
def restore_goal_state(session_id: str, snapshot: Any, *, profile_home: str | Path | None = None) -> None:
"""Restore a prior goal state after kickoff stream creation fails."""
mgr = _manager(str(session_id or ""), profile_home=profile_home)
if mgr is None:
return
if snapshot is None:
try:
mgr.clear()
except Exception:
pass
return
if isinstance(mgr, _ProfileGoalManager):
mgr._state = snapshot
mgr._save(snapshot)
return
try:
from hermes_cli.goals import save_goal # type: ignore
save_goal(str(session_id or ""), snapshot)
except Exception as exc: # pragma: no cover - native fallback only
logger.debug("Goal state restore failed for %s: %s", session_id, exc)
def goal_command_payload(
session_id: str,
args: str = "",
*,
stream_running: bool = False,
profile_home: str | Path | None = None,
) -> Dict[str, Any]:
"""Return the WebUI response payload for a /goal command.
Mirrors the gateway command semantics:
- /goal or /goal status shows status
- /goal pause pauses
- /goal resume resumes without auto-starting a turn
- /goal clear|stop|done clears
- /goal <text> sets a new active goal and returns kickoff_prompt so the
caller can start the first normal user-role turn immediately.
"""
sid = str(session_id or "").strip()
if not sid:
return _payload(ok=False, action="error", error="missing_session", message="session_id required")
mgr = _manager(sid, profile_home=profile_home)
if mgr is None:
return _payload(ok=False, action="error", error="unavailable", message="Goals unavailable on this session.")
text = str(args or "").strip()
lower = text.lower()
if not text or lower == "status":
return _payload(action="status", message=mgr.status_line(), state=getattr(mgr, "state", None))
if lower == "pause":
state = mgr.pause(reason="user-paused")
if state is None:
return _payload(ok=False, action="pause", error="no_goal", message="No goal set.")
return _payload(action="pause", message=f"⏸ Goal paused: {state.goal}", state=state)
if lower == "resume":
state = mgr.resume()
if state is None:
return _payload(ok=False, action="resume", error="no_goal", message="No goal to resume.")
return _payload(
action="resume",
message=(
f"▶ Goal resumed: {state.goal}\n"
"Send a new message, or type continue, to kick it off."
),
state=state,
)
if lower in ("clear", "stop", "done"):
had = bool(mgr.has_goal())
mgr.clear()
return _payload(
action="clear",
message="Goal cleared." if had else "No active goal.",
state=getattr(mgr, "state", None),
)
if stream_running:
return _payload(
ok=False,
action="set",
error="agent_running",
message=(
"Agent is running — use /goal status / pause / clear mid-run, "
"or /stop before setting a new goal."
),
)
try:
state = mgr.set(text)
except ValueError as exc:
return _payload(ok=False, action="set", error="invalid_goal", message=f"Invalid goal: {exc}")
return _payload(
action="set",
message=(
f"⊙ Goal set ({state.max_turns}-turn budget): {state.goal}\n"
"I'll keep working until the goal is done, you pause/clear it, or the budget is exhausted.\n"
"Controls: /goal status · /goal pause · /goal resume · /goal clear"
),
state=state,
kickoff_prompt=state.goal,
)
def has_active_goal(
session_id: str,
*,
profile_home: str | Path | None = None,
) -> bool:
"""Return True when the session has an active standing goal to evaluate."""
sid = str(session_id or "").strip()
if not sid:
return False
mgr = _manager(sid, profile_home=profile_home)
if mgr is None:
return False
try:
return bool(mgr.is_active())
except Exception as exc:
logger.debug("goal active-state check failed for session=%s: %s", sid, exc)
return False
def evaluate_goal_after_turn(
session_id: str,
last_response: str,
*,
user_initiated: bool = True,
profile_home: str | Path | None = None,
) -> Dict[str, Any]:
"""Evaluate a completed turn against the standing goal, if any."""
sid = str(session_id or "").strip()
if not sid:
return {
"status": None,
"should_continue": False,
"continuation_prompt": None,
"verdict": "inactive",
"reason": "missing session_id",
"message": "",
}
mgr = _manager(sid, profile_home=profile_home)
if mgr is None:
return {
"status": None,
"should_continue": False,
"continuation_prompt": None,
"verdict": "inactive",
"reason": "goals unavailable",
"message": "",
}
try:
if not mgr.is_active():
return {
"status": getattr(getattr(mgr, "state", None), "status", None),
"should_continue": False,
"continuation_prompt": None,
"verdict": "inactive",
"reason": "no active goal",
"message": "",
}
decision = mgr.evaluate_after_turn(str(last_response or ""), user_initiated=user_initiated)
except Exception as exc:
logger.debug("goal evaluation failed for session=%s: %s", sid, exc)
return {
"status": None,
"should_continue": False,
"continuation_prompt": None,
"verdict": "error",
"reason": f"goal evaluation failed: {type(exc).__name__}",
"message": "",
}
if not isinstance(decision, dict):
decision = {}
decision.setdefault("should_continue", False)
decision.setdefault("continuation_prompt", None)
decision.setdefault("message", "")
return decision
+206 -59
View File
@@ -4269,6 +4269,9 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/background":
return _handle_background(handler, body)
if parsed.path == "/api/goal":
return _handle_goal_command(handler, body)
if parsed.path == "/api/chat/start":
return _handle_chat_start(handler, body, diag=diag)
@@ -6438,6 +6441,197 @@ def _prepare_chat_start_session_for_stream(
s.save()
def _start_chat_stream_for_session(
s,
*,
msg: str,
attachments=None,
workspace: str,
model: str,
model_provider=None,
normalized_model: bool = False,
diag=None,
):
"""Persist pending state, register an SSE channel, and start an agent turn."""
attachments = attachments or []
# Prevent duplicate runs in the same session while a stream is still active.
# This commonly happens after page refresh/reconnect races and can produce
# duplicated clarify cards for what appears to be a single user request.
diag.stage("active_stream_check") if diag else None
current_stream_id = getattr(s, "active_stream_id", None)
if current_stream_id:
diag.stage("active_stream_lock_wait") if diag else None
with STREAMS_LOCK:
current_active = current_stream_id in STREAMS
if current_active:
diag.stage("response_write") if diag else None
return {
"error": "session already has an active stream",
"active_stream_id": current_stream_id,
"_status": 409,
}
# Stale stream id from a previous run; clear and continue.
diag.stage("stale_stream_cleanup") if diag else None
_clear_stale_stream_state(s)
stream_id = uuid.uuid4().hex
session_lock = _get_session_agent_lock(s.session_id)
diag.stage("session_lock_wait") if diag else None
with session_lock:
diag.stage("save_pending_state") if diag else None
_prepare_chat_start_session_for_stream(
s,
msg=msg,
attachments=attachments,
workspace=workspace,
model=model,
model_provider=model_provider,
stream_id=stream_id,
)
diag.stage("set_last_workspace") if diag else None
set_last_workspace(workspace)
diag.stage("stream_registration") if diag else None
stream = create_stream_channel()
with STREAMS_LOCK:
STREAMS[stream_id] = stream
diag.stage("worker_thread_start") if diag else None
thr = threading.Thread(
target=_run_agent_streaming,
args=(s.session_id, msg, model, workspace, stream_id, attachments),
kwargs={"model_provider": model_provider},
daemon=True,
)
thr.start()
response = {
"stream_id": stream_id,
"session_id": s.session_id,
"pending_started_at": s.pending_started_at,
}
if normalized_model:
response["effective_model"] = model
if model_provider:
response["effective_model_provider"] = model_provider
return response
def _handle_goal_command(handler, body):
"""Handle WebUI /goal command controls and optional kickoff stream."""
try:
require(body, "session_id")
except ValueError as e:
return bad(handler, str(e))
try:
s = get_session(body["session_id"])
except KeyError:
return bad(handler, "Session not found", 404)
requested_profile = str(body.get("profile") or "").strip()
if requested_profile:
try:
from api.profiles import _PROFILE_ID_RE
if requested_profile != "default" and not _PROFILE_ID_RE.fullmatch(requested_profile):
return bad(handler, "invalid profile", 400)
except ImportError:
requested_profile = ""
if requested_profile and not _profiles_match(getattr(s, "profile", None), requested_profile):
has_persisted_turns = bool(
getattr(s, "messages", None)
or getattr(s, "context_messages", None)
or getattr(s, "pending_user_message", None)
)
if not has_persisted_turns:
s.profile = requested_profile
current_stream_id = getattr(s, "active_stream_id", None)
stream_running = False
if current_stream_id:
with STREAMS_LOCK:
stream_running = current_stream_id in STREAMS
if not stream_running:
_clear_stale_stream_state(s)
try:
from api.profiles import get_hermes_home_for_profile
profile_home = get_hermes_home_for_profile(getattr(s, "profile", None))
except Exception:
profile_home = None
from api.goals import goal_command_payload, goal_state_snapshot, restore_goal_state
goal_args = str(body.get("args", "") or body.get("text", "") or "")
goal_action = goal_args.strip().lower()
will_kickoff = bool(
goal_args.strip()
and goal_action not in ("status", "pause", "resume", "clear", "stop", "done")
and not stream_running
)
workspace = model = model_provider = normalized_model = None
previous_goal_state = None
if will_kickoff:
try:
workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace))
except ValueError as e:
return bad(handler, str(e))
requested_model = body.get("model") or s.model
requested_provider = (
body.get("model_provider")
if "model_provider" in body
else getattr(s, "model_provider", None)
)
model, model_provider, normalized_model = _resolve_compatible_session_model_state(
requested_model,
requested_provider,
)
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,
)
if not payload.get("ok", True):
status = 409 if payload.get("error") == "agent_running" else 400
return j(handler, payload, status=status)
kickoff_prompt = str(payload.get("kickoff_prompt") or "").strip()
if kickoff_prompt:
if workspace is None:
try:
workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace))
except ValueError as e:
return bad(handler, str(e))
if model is None:
requested_model = body.get("model") or s.model
requested_provider = (
body.get("model_provider")
if "model_provider" in body
else getattr(s, "model_provider", None)
)
model, model_provider, normalized_model = _resolve_compatible_session_model_state(
requested_model,
requested_provider,
)
stream_response = _start_chat_stream_for_session(
s,
msg=kickoff_prompt,
attachments=[],
workspace=workspace,
model=model,
model_provider=model_provider,
normalized_model=normalized_model,
)
status = int(stream_response.pop("_status", 200) or 200)
payload.update(stream_response)
if status >= 400:
restore_goal_state(s.session_id, previous_goal_state, profile_home=profile_home)
payload["ok"] = False
return j(handler, payload, status=status)
return j(handler, payload)
def _handle_chat_start(handler, body, diag=None):
try:
diag.stage("validate_session_id") if diag else None
@@ -6494,72 +6688,25 @@ def _handle_chat_start(handler, body, diag=None):
requested_model,
requested_provider,
)
# Prevent duplicate runs in the same session while a stream is still active.
# This commonly happens after page refresh/reconnect races and can produce
# duplicated clarify cards for what appears to be a single user request.
diag.stage("active_stream_check") if diag else None
current_stream_id = getattr(s, "active_stream_id", None)
if current_stream_id:
diag.stage("active_stream_lock_wait") if diag else None
with STREAMS_LOCK:
current_active = current_stream_id in STREAMS
if current_active:
diag.stage("response_write") if diag else None
return j(
handler,
{
"error": "session already has an active stream",
"active_stream_id": current_stream_id,
},
status=409,
)
# Stale stream id from a previous run; clear and continue.
diag.stage("stale_stream_cleanup") if diag else None
_clear_stale_stream_state(s)
stream_id = uuid.uuid4().hex
session_lock = _get_session_agent_lock(s.session_id)
diag.stage("session_lock_wait") if diag else None
with session_lock:
diag.stage("save_pending_state") if diag else None
_prepare_chat_start_session_for_stream(
s,
msg=msg,
attachments=attachments,
workspace=workspace,
model=model,
model_provider=model_provider,
stream_id=stream_id,
)
diag.stage("set_last_workspace") if diag else None
set_last_workspace(workspace)
diag.stage("stream_registration") if diag else None
stream = create_stream_channel()
with STREAMS_LOCK:
STREAMS[stream_id] = stream
diag.stage("worker_thread_start") if diag else None
thr = threading.Thread(
target=_run_agent_streaming,
args=(s.session_id, msg, model, workspace, stream_id, attachments),
kwargs={"model_provider": model_provider},
daemon=True,
response = _start_chat_stream_for_session(
s,
msg=msg,
attachments=attachments,
workspace=workspace,
model=model,
model_provider=model_provider,
normalized_model=normalized_model,
diag=diag,
)
thr.start()
response = {
"stream_id": stream_id,
"session_id": s.session_id,
"pending_started_at": s.pending_started_at,
}
if normalized_model:
response["effective_model"] = model
if model_provider:
response["effective_model_provider"] = model_provider
status = int(response.pop("_status", 200) or 200)
diag.stage("response_write") if diag else None
return j(handler, response)
return j(handler, response, status=status)
finally:
if diag:
diag.finish()
def _normalize_chat_attachments(raw_attachments):
"""Normalize attachment payloads from the browser.
+58
View File
@@ -3227,6 +3227,64 @@ def _run_agent_streaming(
})
except Exception:
logger.debug("Failed to drain pending steer for session %s", session_id)
# /goal parity: after a successful assistant turn, run the Hermes
# GoalManager judge before terminal done/stream_end events. The
# frontend surfaces the status line and queues continuation_prompt as
# a normal next user message so /queue and user input keep priority.
try:
from api.goals import evaluate_goal_after_turn, has_active_goal
if not has_active_goal(session_id, profile_home=_profile_home):
_goal_decision = {}
else:
_last_goal_response = ''
for _goal_msg in reversed(s.messages or []):
if not isinstance(_goal_msg, dict) or _goal_msg.get('role') != 'assistant':
continue
_goal_content = _goal_msg.get('content', '')
if isinstance(_goal_content, list):
_goal_parts = []
for _goal_part in _goal_content:
if isinstance(_goal_part, dict):
_goal_text = _goal_part.get('text') or _goal_part.get('content')
if _goal_text:
_goal_parts.append(str(_goal_text))
_last_goal_response = '\n'.join(_goal_parts)
else:
_last_goal_response = str(_goal_content or '')
break
put('goal', {
'session_id': session_id,
'state': 'evaluating',
'message': 'Evaluating goal progress…',
})
_goal_decision = evaluate_goal_after_turn(
session_id,
_last_goal_response,
user_initiated=True,
profile_home=_profile_home,
)
decision = _goal_decision or {}
_goal_message = str(decision.get('message') or '').strip()
if _goal_message:
put('goal', {
'session_id': session_id,
'state': 'continuing' if decision.get('should_continue') else 'idle',
'message': _goal_message,
'decision': decision,
})
if decision.get('should_continue'):
continuation_prompt = str(decision.get('continuation_prompt') or '').strip()
if continuation_prompt:
put('goal_continue', {
'session_id': session_id,
'continuation_prompt': continuation_prompt,
'text': continuation_prompt,
'message': _goal_message,
'decision': decision,
})
except Exception as _goal_exc:
logger.debug("Goal continuation hook failed for session %s: %s", session_id, _goal_exc)
raw_session = s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}
put('done', {'session': redact_session_data(raw_session), 'usage': usage})
# Emit one last metering packet for the live message-header TPS label.
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+48
View File
@@ -18,6 +18,7 @@ const COMMANDS=[
{name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name', subArgs:'personalities'},
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'},
{name:'stop', desc:t('cmd_stop'), fn:cmdStop, noEcho:true},
{name:'goal', desc:t('cmd_goal'), fn:cmdGoal, arg:'[status|pause|resume|clear|text]', subArgs:['status','pause','resume','clear']},
{name:'queue', desc:t('cmd_queue'), fn:cmdQueue, arg:'message', noEcho:true},
{name:'interrupt', desc:t('cmd_interrupt'), fn:cmdInterrupt, arg:'message', noEcho:true},
{name:'steer', desc:t('cmd_steer'), fn:cmdSteer, arg:'message', noEcho:true},
@@ -625,6 +626,53 @@ async function cmdStop(){
else showToast(t('cancel_unavailable'));
}
async function cmdGoal(args){
if(!S.session){await newSession();await renderSessionList();}
if(!S.session||!S.session.session_id){showToast(t('no_active_session'));return;}
const activeSid=S.session.session_id;
try{
const r=await api('/api/goal',{method:'POST',body:JSON.stringify({
session_id:activeSid,
args:args||'',
workspace:S.session.workspace,
model:S.session.model||($('modelSelect')&&$('modelSelect').value)||'',
model_provider:S.session.model_provider||null,
profile:S.activeProfile||S.session.profile||'default',
})});
const msg=String((r&&r.message)||'').trim();
if(msg){
S.messages.push({role:'assistant',content:msg,_ts:Date.now()/1000,_goalStatus:true,_transient:true});
renderMessages({preserveScroll:true});
showToast(msg.split('\n')[0],2600);
}
if(!r||!r.stream_id)return;
S.toolCalls=[];
if(typeof clearLiveToolCards==='function')clearLiveToolCards();
appendThinking();setBusy(true);
setComposerStatus('Working toward goal…');
S.activeStreamId=r.stream_id;
if(S.session&&S.session.session_id===activeSid){
S.session.active_stream_id=r.stream_id;
if(typeof r.pending_started_at==='number')S.session.pending_started_at=r.pending_started_at;
if(r.effective_model)S.session.model=r.effective_model;
if(r.effective_model_provider)S.session.model_provider=r.effective_model_provider;
}
INFLIGHT[activeSid]={messages:[...S.messages],uploaded:[],toolCalls:[]};
if(typeof markInflight==='function')markInflight(activeSid,r.stream_id);
if(typeof saveInflightState==='function')saveInflightState(activeSid,{streamId:r.stream_id,messages:INFLIGHT[activeSid].messages,uploaded:[],toolCalls:[]});
startApprovalPolling(activeSid);
startClarifyPolling(activeSid);
if(typeof _fetchYoloState==='function')_fetchYoloState(activeSid);
attachLiveStream(activeSid,r.stream_id,[]);
if(typeof renderSessionList==='function')void renderSessionList();
}catch(e){
const err=String((e&&e.message)||e||'Goal command failed');
S.messages.push({role:'assistant',content:`**Goal command failed:** ${err}`,_ts:Date.now()/1000,_error:true});
renderMessages({preserveScroll:true});
showToast(err,3000);
}
}
// ── Busy-input mode commands ──────────────────────────────────────────────
// These commands let users override the default busy_input_mode setting for a
// specific message. They are only meaningful while the agent is running.
+9
View File
@@ -183,6 +183,7 @@ const LOCALES = {
theme_set: 'Theme: ',
no_active_session: 'No active session',
cmd_queue: 'Queue a message for the next turn',
cmd_goal: 'Set or inspect a persistent goal',
cmd_interrupt: 'Cancel current turn and send a new message',
cmd_steer: 'Inject a mid-turn correction without interrupting the agent',
cmd_queue_no_msg: 'Usage: /queue <message>',
@@ -1204,6 +1205,7 @@ const LOCALES = {
theme_set: 'テーマ: ',
no_active_session: 'アクティブなセッションがありません',
cmd_queue: '次のターン用にメッセージをキュー',
cmd_goal: '永続ゴールを設定または確認',
cmd_interrupt: '現在のターンをキャンセルして新規メッセージを送信',
cmd_steer: 'エージェントを中断せずにターン中に修正を注入',
cmd_queue_no_msg: '使い方: /queue <メッセージ>',
@@ -2186,6 +2188,7 @@ const LOCALES = {
theme_set: 'Тема: ',
no_active_session: 'Нет активной сессии',
cmd_queue: 'Поставить сообщение в очередь на следующий оборот',
cmd_goal: 'Задать или проверить постоянную цель',
cmd_interrupt: 'Прервать текущий оборот и отправить новое сообщение',
cmd_steer: 'Направить агента исправлением (переходит к прерыванию)',
cmd_queue_no_msg: 'Использование: /queue <сообщение>',
@@ -3173,6 +3176,7 @@ const LOCALES = {
theme_set: 'Tema: ',
no_active_session: 'No hay ninguna sesión activa',
cmd_queue: 'Poner mensaje en cola para el siguiente turno',
cmd_goal: 'Definir o consultar un objetivo persistente',
cmd_interrupt: 'Cancelar turno actual y enviar nuevo mensaje',
cmd_steer: 'Inyectar una corrección a mitad del turno sin interrumpir al agente',
cmd_queue_no_msg: 'Uso: /queue <mensaje>',
@@ -4109,6 +4113,7 @@ const LOCALES = {
model_scope_advisory: 'Gilt für diesen Chat ab Ihrer nächsten Nachricht.',
model_scope_toast: 'Gilt für diesen Chat ab Ihrer nächsten Nachricht.',
cmd_queue: 'Nachricht f\u00fcr den n\u00e4chsten Durchgang einreihen',
cmd_goal: 'Ein dauerhaftes Ziel setzen oder prüfen',
cmd_interrupt: 'Aktuellen Durchgang abbrechen und neue Nachricht senden',
cmd_steer: 'Korrektursignal einf\u00fcgen ohne Unterbrechung',
cmd_queue_no_msg: 'Verwendung: /queue <Nachricht>',
@@ -5082,6 +5087,7 @@ const LOCALES = {
theme_set: '\u4e3b\u9898\uff1a',
no_active_session: '\u5f53\u524d\u6ca1\u6709\u6d3b\u52a8\u4f1a\u8bdd',
cmd_queue: '\u5c06\u6d88\u606f\u52a0\u5165\u4e0b\u4e00\u8f6e\u7684\u961f\u5217',
cmd_goal: '设置或查看持久目标',
cmd_interrupt: '\u53d6\u6d88\u5f53\u524d\u56de\u5408\u5e76\u53d1\u9001\u65b0\u6d88\u606f',
cmd_steer: '\u7528\u7ea0\u6b63\u4fe1\u606f\u5f15\u5bfc\u4ee3\u7406\uff08\u56de\u9000\u4e3a\u4e2d\u65ad\uff09',
cmd_queue_no_msg: '\u7528\u6cd5\uff1a/queue <\u6d88\u606f>',
@@ -6459,6 +6465,7 @@ const LOCALES = {
never: '\u5f9e\u4e0d',
no_active_session: '\u7121\u6d3b\u8e8d\u6703\u8a71',
cmd_queue: '\u5c07\u8a0a\u606f\u52a0\u5165\u4e0b\u4e00\u8f2a\u7684\u4f47\u5217',
cmd_goal: '設定或查看持久目標',
cmd_interrupt: '\u53d6\u6d88\u7576\u524d\u56de\u5408\u4e26\u767c\u9001\u65b0\u8a0a\u606f',
cmd_steer: '\u5728\u56de\u5408\u9032\u884c\u4e2d\u6ce8\u5165\u7d3a\u6b63\uff0c\u4e0d\u4e2d\u65b7\u4ee3\u7406',
cmd_queue_no_msg: '\u7528\u6cd5\uff1a/queue <\u8a0a\u606f>',
@@ -6972,6 +6979,7 @@ const LOCALES = {
theme_set: 'Tema: ',
no_active_session: 'Nenhuma sessão ativa',
cmd_queue: 'Enfileirar mensagem para o próximo turno',
cmd_goal: 'Definir ou consultar uma meta persistente',
cmd_interrupt: 'Cancelar turno atual e enviar nova mensagem',
cmd_steer: 'Injetar correção no meio do turno sem interromper',
cmd_queue_no_msg: 'Uso: /queue <mensagem>',
@@ -7869,6 +7877,7 @@ const LOCALES = {
theme_set: 'Theme: ',
no_active_session: '활성 세션 없음',
cmd_queue: 'Queue a message for the next turn',
cmd_goal: '지속 목표를 설정하거나 확인',
cmd_interrupt: 'Cancel current turn and send a new message',
cmd_steer: 'Inject a mid-turn correction without interrupting the agent',
cmd_queue_no_msg: 'Usage: /queue <message>',
+59 -1
View File
@@ -63,7 +63,7 @@ async function send(){
// cmdSteer / cmdInterrupt say "No active task to stop."
if(text.startsWith('/')){
const _pc=typeof parseCommand==='function'&&parseCommand(text);
if(_pc&&['steer','interrupt','queue','terminal'].includes(_pc.name)){
if(_pc&&['steer','interrupt','queue','terminal','goal'].includes(_pc.name)){
const _bc=COMMANDS.find(c=>c.name===_pc.name);
if(_bc){
$('msg').value='';autoResize();
@@ -336,6 +336,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
let assistantText='';
let reasoningText='';
let liveReasoningText='';
let _latestGoalStatus=null;
let _pendingGoalContinuation=null;
let assistantRow=null;
let assistantBody=null;
let segmentStart=0; // char offset in assistantText where current segment begins
@@ -879,6 +881,41 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}catch(_){}
});
source.addEventListener('goal',e=>{
try{
const d=JSON.parse(e.data||'{}');
if((d.session_id||activeSid)!==activeSid) return;
const goalState=String(d.state||'').trim();
const goalEvaluatingMessage='Evaluating goal progress…';
if(goalState==='evaluating'){
setComposerStatus(goalEvaluatingMessage);
return;
}
const msg=String(d.message||'').trim();
if(!msg)return;
_latestGoalStatus={message:msg,decision:d.decision||null,state:goalState||null};
setComposerStatus(msg);
showToast(msg.split('\n')[0],2600);
}catch(_){}
});
source.addEventListener('goal_continue',e=>{
try{
const d=JSON.parse(e.data||'{}');
const sid=d.session_id||activeSid;
const continuation_prompt=String(d.continuation_prompt||d.text||'').trim();
if(!continuation_prompt||sid!==activeSid)return;
_pendingGoalContinuation={
sid,
text:continuation_prompt,
model:S.session&&S.session.model||'',
model_provider:S.session&&S.session.model_provider||null,
profile:S.activeProfile||'default',
};
showToast('Continuing toward goal…',2200);
}catch(_){}
});
source.addEventListener('done',e=>{
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
@@ -992,6 +1029,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const lastUser=[...S.messages].reverse().find(m=>m.role==='user');
if(lastUser)lastUser.attachments=uploaded;
}
if(_latestGoalStatus&&_latestGoalStatus.message){
S.messages.push({
role:'assistant',
content:String(_latestGoalStatus.message),
_ts:Date.now()/1000,
_goalStatus:true,
_transient:true,
});
}
clearLiveToolCards();
S.busy=false;
// No-reply guard (#373): if agent returned nothing, show inline error
@@ -1003,6 +1049,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// TTS auto-read: speak the last assistant response if enabled (#499)
if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300);
}
if(isActiveSession&&_pendingGoalContinuation&&typeof queueSessionMessage==='function'){
const _goalNext=_pendingGoalContinuation;
_pendingGoalContinuation=null;
queueSessionMessage(_goalNext.sid,{
text:_goalNext.text,
files:[],
model:_goalNext.model,
model_provider:_goalNext.model_provider,
profile:_goalNext.profile,
});
if(typeof updateQueueBadge==='function')updateQueueBadge(_goalNext.sid);
}
if(isActiveSession) _queueDrainSid=activeSid;
renderSessionList();
_setActivePaneIdleIfOwner();
+272
View File
@@ -0,0 +1,272 @@
"""Regression tests for first-class WebUI /goal command parity."""
import io
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8")
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
ROUTES_PY = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
STREAMING_PY = (REPO_ROOT / "api" / "streaming.py").read_text(encoding="utf-8")
def test_goal_command_payload_matches_gateway_controls(monkeypatch):
"""The backend command helper mirrors gateway /goal status/pause/resume/clear/set."""
from api import goals as webui_goals
calls = []
class FakeState:
goal = "ship the feature"
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):
calls.append(("init", session_id, default_max_turns))
self.state = None
def status_line(self):
return "No active goal. Set one with /goal <text>."
def pause(self, reason="user-paused"):
calls.append(("pause", reason))
return FakeState()
def resume(self, reset_budget=True):
calls.append(("resume", reset_budget))
return FakeState()
def has_goal(self):
return True
def clear(self):
calls.append(("clear",))
def set(self, goal):
calls.append(("set", goal))
state = FakeState()
state.goal = goal
self.state = state
return state
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
monkeypatch.setattr(webui_goals, "_default_max_turns", lambda: 20)
status = webui_goals.goal_command_payload("sid-123", "status")
pause = webui_goals.goal_command_payload("sid-123", "pause")
resume = webui_goals.goal_command_payload("sid-123", "resume")
clear = webui_goals.goal_command_payload("sid-123", "clear")
set_goal = webui_goals.goal_command_payload("sid-123", "ship the feature")
assert status["message"] == "No active goal. Set one with /goal <text>."
assert pause["message"] == "⏸ Goal paused: ship the feature"
assert resume["message"].startswith("▶ Goal resumed: ship the feature")
assert clear["message"] == "Goal cleared."
assert set_goal["action"] == "set"
assert set_goal["kickoff_prompt"] == "ship the feature"
assert "⊙ Goal set (20-turn budget): ship the feature" in set_goal["message"]
assert ("set", "ship the feature") in calls
def test_goal_command_payload_rejects_new_goal_while_stream_running(monkeypatch):
"""Status/control subcommands are safe mid-run; replacing the goal is not."""
from api import goals as webui_goals
class FakeGoalManager:
def __init__(self, session_id, default_max_turns=20):
pass
def status_line(self):
return "⊙ Goal (active, 1/20 turns): existing"
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
monkeypatch.setattr(webui_goals, "_default_max_turns", lambda: 20)
status = webui_goals.goal_command_payload("sid-123", "status", stream_running=True)
rejected = webui_goals.goal_command_payload("sid-123", "replace it", stream_running=True)
assert status["ok"] is True
assert rejected["ok"] is False
assert rejected["error"] == "agent_running"
assert "use /goal status / pause / clear mid-run" in rejected["message"]
def test_has_active_goal_reports_only_active_state(monkeypatch):
"""Streaming can avoid showing an evaluating spinner when no standing goal is active."""
from api import goals as webui_goals
class FakeGoalManager:
def __init__(self, session_id, default_max_turns=20):
self.session_id = session_id
def is_active(self):
return self.session_id == "sid-active-goal"
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
monkeypatch.setattr(webui_goals, "_default_max_turns", lambda: 20)
assert webui_goals.has_active_goal("sid-active-goal") is True
assert webui_goals.has_active_goal("sid-idle-goal") is False
assert webui_goals.has_active_goal("") is False
def test_goal_continuation_decision_emits_status_and_normal_user_prompt(monkeypatch):
"""Post-turn hook returns the visible status event plus a normal continuation prompt."""
from api import goals as webui_goals
class FakeGoalManager:
def __init__(self, session_id, default_max_turns=20):
self.session_id = session_id
def is_active(self):
return True
def evaluate_after_turn(self, last_response, user_initiated=True):
return {
"status": "active",
"should_continue": True,
"continuation_prompt": "[Continuing toward your standing goal]\nGoal: ship it",
"verdict": "continue",
"reason": "one step remains",
"message": "↻ Continuing toward goal (1/20): one step remains",
}
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
monkeypatch.setattr(webui_goals, "_default_max_turns", lambda: 20)
decision = webui_goals.evaluate_goal_after_turn("sid-123", "not done yet", user_initiated=False)
assert decision["message"].startswith("↻ Continuing toward goal")
assert decision["should_continue"] is True
assert decision["continuation_prompt"].startswith("[Continuing toward your standing goal]")
def test_goal_endpoint_sets_goal_and_starts_kickoff_stream(monkeypatch, tmp_path):
"""POST /api/goal uses GoalManager state and launches the first goal turn."""
from api import goals as webui_goals
from api import routes
class FakeState:
goal = "ship the feature"
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.session_id = session_id
self.default_max_turns = default_max_turns
def set(self, goal):
state = FakeState()
state.goal = goal
return 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.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),
)
started = []
def fake_start(session, **kwargs):
started.append(kwargs)
return {"stream_id": "goal-stream", "session_id": session.session_id, "pending_started_at": 123.0}
monkeypatch.setattr(routes, "_start_chat_stream_for_session", fake_start)
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 the feature",
"workspace": str(tmp_path),
"model": "gpt-5.5",
"model_provider": "openai-codex",
},
)
assert result["status"] == 200
assert result["payload"]["action"] == "set"
assert result["payload"]["stream_id"] == "goal-stream"
assert started and started[0]["msg"] == "ship the feature"
assert started[0]["model_provider"] == "openai-codex"
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
assert "goal_command_payload" in ROUTES_PY
assert "kickoff_prompt" in ROUTES_PY
assert "_start_chat_stream_for_session" in ROUTES_PY
def test_streaming_post_turn_goal_hook_surfaces_and_continues():
assert "evaluate_goal_after_turn" in STREAMING_PY
assert "put('goal'" in STREAMING_PY
assert "decision.get('should_continue')" in STREAMING_PY
assert "continuation_prompt" in STREAMING_PY
assert "put('goal_continue'" in STREAMING_PY
goal_idx = STREAMING_PY.find("evaluate_goal_after_turn")
done_idx = STREAMING_PY.find("put('done'", goal_idx)
assert goal_idx != -1 and done_idx != -1
assert goal_idx < done_idx, "goal status should be emitted before the terminal done payload"
def test_streaming_goal_hook_emits_evaluating_state_before_judge():
evaluating_idx = STREAMING_PY.find("'state': 'evaluating'")
judge_idx = STREAMING_PY.find("_goal_decision = evaluate_goal_after_turn")
done_idx = STREAMING_PY.find("put('done'", judge_idx)
assert evaluating_idx != -1, "goal hook should emit an evaluating state before judge round-trip"
assert judge_idx != -1 and done_idx != -1
assert evaluating_idx < judge_idx < done_idx
assert "Evaluating goal progress…" in STREAMING_PY
assert "'state': 'continuing' if decision.get('should_continue') else 'idle'" in STREAMING_PY
def test_frontend_has_goal_slash_command_and_status_event_handler():
assert "{name:'goal'" in COMMANDS_JS
assert "subArgs:['status','pause','resume','clear']" in COMMANDS_JS
assert "function cmdGoal" in COMMANDS_JS
assert "api('/api/goal'" in COMMANDS_JS
assert "stream_id" in COMMANDS_JS
assert "goal'" in MESSAGES_JS
assert "source.addEventListener('goal'" in MESSAGES_JS
assert "source.addEventListener('goal_continue'" in MESSAGES_JS
assert "['steer','interrupt','queue','terminal','goal'].includes(_pc.name)" in MESSAGES_JS
assert "queueSessionMessage" in MESSAGES_JS
def test_frontend_goal_evaluating_state_uses_calm_composer_indicator():
assert "const goalState=String(d.state||'').trim();" in MESSAGES_JS
assert "const goalEvaluatingMessage='Evaluating goal progress…';" in MESSAGES_JS
assert "if(goalState==='evaluating')" in MESSAGES_JS
assert "setComposerStatus(goalEvaluatingMessage);" in MESSAGES_JS
assert "return;" in MESSAGES_JS
+60 -16
View File
@@ -49,26 +49,67 @@ def _cleanup_state_dir(state_dir: Path):
def _reimport_mcp():
"""Re-import mcp_server with current env vars and profile.
"""Re-point mcp_server's module-level STATE_DIR / SESSION_DIR /
SESSION_INDEX_FILE / PROJECTS_FILE constants at the current
HERMES_WEBUI_STATE_DIR.
Returns (mcp_module, profiles_module) profiles_module is the
live api.profiles reference that the re-imported mcp_server uses.
live api.profiles reference.
NOTE: Does NOT use `del sys.modules[...]` or `importlib.reload(...)`.
Both patterns trigger a chain re-import inside the FastMCP / pydantic
stack that corrupts pydantic's `_generics._GENERIC_TYPES_CACHE`
(manifests as `KeyError: 'pydantic.root_model'` in unrelated
downstream tests in the full suite). Instead, we mutate the
constants in-place after the first one-time import, which is
behaviorally equivalent for these tests since the constants are
module-level Path objects used only to compute STATE_DIR-rooted
paths at call time.
"""
# Clear cached module and api submodules that cache paths
for key in list(sys.modules.keys()):
if key == 'mcp_server' or key.startswith('mcp_server.') or \
key == 'api.config' or key == 'api.models' or key == 'api.profiles':
del sys.modules[key]
state_dir = Path(os.environ['HERMES_WEBUI_STATE_DIR'])
import importlib
import api.config as cfg
importlib.reload(cfg)
# Re-acquire api.profiles reference (old one is stale after sys.modules clear)
import api.profiles as fresh_profiles
fresh_profiles._active_profile = 'default'
import mcp_server as mod
# Re-point api.config module-level constants
cfg.STATE_DIR = state_dir
cfg.SESSION_DIR = state_dir / "sessions"
cfg.WORKSPACES_FILE = state_dir / "workspaces.json"
cfg.SETTINGS_FILE = state_dir / "settings.json"
cfg.LAST_WORKSPACE_FILE = state_dir / "last_workspace.txt"
cfg.PROJECTS_FILE = state_dir / "projects.json"
if hasattr(cfg, 'SESSION_INDEX_FILE'):
cfg.SESSION_INDEX_FILE = state_dir / "sessions" / "_index.json"
# Re-point mcp_server's imported aliases (they were copied at first
# import and don't pick up cfg mutations automatically).
mod.STATE_DIR = cfg.STATE_DIR
mod.SESSION_DIR = cfg.SESSION_DIR
mod.PROJECTS_FILE = cfg.PROJECTS_FILE
if hasattr(mod, 'SESSION_INDEX_FILE'):
mod.SESSION_INDEX_FILE = cfg.SESSION_INDEX_FILE
# api.models also imports STATE_DIR / PROJECTS_FILE etc. as module
# constants — re-point those too so load_projects() / save_projects()
# see the fresh STATE_DIR.
if 'api.models' in sys.modules:
models_mod = sys.modules['api.models']
if hasattr(models_mod, 'STATE_DIR'):
models_mod.STATE_DIR = cfg.STATE_DIR
if hasattr(models_mod, 'PROJECTS_FILE'):
models_mod.PROJECTS_FILE = cfg.PROJECTS_FILE
if hasattr(models_mod, 'SESSION_DIR'):
models_mod.SESSION_DIR = cfg.SESSION_DIR
# Re-evaluate WEBUI_URL from current env (PR #1895 made it env-aware
# but the value is computed once at module load; tests need to see
# current env state).
mod.WEBUI_HOST = os.environ.get("HERMES_WEBUI_HOST", "127.0.0.1")
mod.WEBUI_PORT = os.environ.get("HERMES_WEBUI_PORT", "8787")
mod.WEBUI_URL = f"http://{mod.WEBUI_HOST}:{mod.WEBUI_PORT}"
fresh_profiles._active_profile = 'default'
return mod, fresh_profiles
@@ -292,7 +333,8 @@ class TestProfileScoping:
to 'default'. A non-root profile must NOT see legacy untagged rows.
"""
# Manually write a legacy untagged project (pre-#1614 schema)
from api.config import PROJECTS_FILE
import api.config as _cfg_mod
PROJECTS_FILE = _cfg_mod.PROJECTS_FILE
legacy = [{
"project_id": "legacy000001",
"name": "LegacyUntagged",
@@ -317,7 +359,8 @@ class TestProfileScoping:
async def test_legacy_untagged_rename_blocked_from_non_root(self):
"""Non-root profile cannot rename a legacy untagged project."""
from api.config import PROJECTS_FILE
import api.config as _cfg_mod
PROJECTS_FILE = _cfg_mod.PROJECTS_FILE
legacy = [{
"project_id": "legacy000002",
"name": "Legacy",
@@ -517,7 +560,8 @@ class TestProfileCliOrdering:
_profiles._active_profile = _profile_arg right after import). If a
helper had latched the profile at import time, the override here
would be too late and the test would see 'default'-tagged rows."""
from api.config import PROJECTS_FILE
import api.config as _cfg_mod
PROJECTS_FILE = _cfg_mod.PROJECTS_FILE
# Pre-seed two projects: one for default, one for foo.
seeded = [
{"project_id": "p_default_0001", "name": "DefaultRow",