#6962 adds 'stop' to the busy-command passthrough list in messages.js so a busy /stop cancels immediately. test_frontend_has_goal_slash_command_and_status_event_handler asserted the exact list literal, so it failed on a change that does not affect /goal. Assert that 'goal' is a member of the list instead. Verified the assertion still fails when 'goal' is actually removed from the list.
1072 lines
41 KiB
Python
1072 lines
41 KiB
Python
"""Regression tests for first-class WebUI /goal command parity."""
|
|
|
|
import io
|
|
import json
|
|
import re
|
|
import threading
|
|
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 status["message_key"] == "goal_status_none"
|
|
assert pause["message"] == "⏸ Goal paused: ship the feature"
|
|
assert pause["message_key"] == "goal_paused"
|
|
assert pause["message_args"] == ["ship the feature"]
|
|
assert resume["message"].startswith("▶ Goal resumed: ship the feature")
|
|
assert resume["message_key"] == "goal_resumed"
|
|
assert resume["message_args"] == ["ship the feature"]
|
|
assert clear["message"] == "Goal cleared."
|
|
assert clear["message_key"] == "goal_cleared"
|
|
assert set_goal["action"] == "set"
|
|
assert set_goal["message_key"] == "goal_set"
|
|
assert set_goal["message_args"] == [20, "ship the feature"]
|
|
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_profile_goal_evaluation_supports_native_judge_contract(monkeypatch, tmp_path):
|
|
"""Profile goals accept the current five-field native judge result."""
|
|
from api import goals as webui_goals
|
|
native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed")
|
|
|
|
judge = lambda *args, **kwargs: ("continue", "work remains", False, None, False)
|
|
monkeypatch.setattr(webui_goals, "judge_goal", judge)
|
|
monkeypatch.setattr(native_goals, "judge_goal", judge)
|
|
monkeypatch.syspath_prepend(str(Path(native_goals.__file__).resolve().parents[1]))
|
|
from hermes_constants import get_hermes_home
|
|
|
|
original_home = get_hermes_home()
|
|
webui_goals._DB_CACHE.clear()
|
|
native_goals._DB_CACHE.clear()
|
|
__import__("hermes_state")
|
|
|
|
home = tmp_path / "profile"
|
|
webui_goals.goal_command_payload("sid-native-contract", "finish it", profile_home=home)
|
|
decision = webui_goals.evaluate_goal_after_turn(
|
|
"sid-native-contract",
|
|
"not finished",
|
|
profile_home=home,
|
|
)
|
|
state = webui_goals.goal_state_snapshot("sid-native-contract", profile_home=home)
|
|
|
|
assert decision["verdict"] == "continue"
|
|
assert decision["should_continue"] is True
|
|
assert state.turns_used == 1
|
|
assert state.last_verdict == "continue"
|
|
|
|
snapshot = webui_goals.goal_state_snapshot("sid-native-contract", profile_home=home)
|
|
webui_goals.goal_command_payload("sid-native-contract", "replacement", profile_home=home)
|
|
webui_goals.restore_goal_state("sid-native-contract", snapshot, profile_home=home)
|
|
restored = webui_goals.goal_state_snapshot("sid-native-contract", profile_home=home)
|
|
assert restored.goal == "finish it"
|
|
assert restored.turns_used == 1
|
|
assert get_hermes_home() == original_home
|
|
|
|
|
|
def test_profile_goal_evaluation_preserves_native_wait_semantics(monkeypatch, tmp_path):
|
|
"""Profile goals park when the native judge returns a wait directive."""
|
|
from api import goals as webui_goals
|
|
native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed")
|
|
|
|
judge = lambda *args, **kwargs: (
|
|
"wait",
|
|
"rate limited",
|
|
False,
|
|
{"seconds": 30},
|
|
False,
|
|
)
|
|
monkeypatch.setattr(webui_goals, "judge_goal", judge)
|
|
monkeypatch.setattr(native_goals, "judge_goal", judge)
|
|
monkeypatch.syspath_prepend(str(Path(native_goals.__file__).resolve().parents[1]))
|
|
webui_goals._DB_CACHE.clear()
|
|
native_goals._DB_CACHE.clear()
|
|
__import__("hermes_state")
|
|
|
|
home = tmp_path / "profile"
|
|
webui_goals.goal_command_payload("sid-native-wait", "finish it", profile_home=home)
|
|
decision = webui_goals.evaluate_goal_after_turn(
|
|
"sid-native-wait",
|
|
"rate limited",
|
|
profile_home=home,
|
|
)
|
|
state = webui_goals.goal_state_snapshot("sid-native-wait", profile_home=home)
|
|
|
|
assert decision["verdict"] == "wait"
|
|
assert decision["should_continue"] is False
|
|
assert state.waiting_until > 0
|
|
assert state.waiting_reason == "rate limited"
|
|
|
|
|
|
def test_profile_goal_context_isolated_across_threads(monkeypatch, tmp_path):
|
|
"""Concurrent profiles with the same session id cannot cross-write goals."""
|
|
from api import goals as webui_goals
|
|
native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed")
|
|
|
|
monkeypatch.syspath_prepend(str(Path(native_goals.__file__).resolve().parents[1]))
|
|
from hermes_constants import get_hermes_home
|
|
|
|
original_home = get_hermes_home()
|
|
webui_goals._DB_CACHE.clear()
|
|
native_goals._DB_CACHE.clear()
|
|
__import__("hermes_state")
|
|
barrier = threading.Barrier(2)
|
|
failures = []
|
|
|
|
def set_goal(profile):
|
|
try:
|
|
barrier.wait()
|
|
result = webui_goals.goal_command_payload(
|
|
"shared-session",
|
|
f"goal-{profile}",
|
|
profile_home=tmp_path / profile,
|
|
)
|
|
assert result["ok"] is True
|
|
except Exception as exc:
|
|
failures.append(exc)
|
|
|
|
threads = [threading.Thread(target=set_goal, args=(profile,)) for profile in ("a", "b")]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
|
|
assert failures == []
|
|
assert webui_goals.goal_state_snapshot(
|
|
"shared-session", profile_home=tmp_path / "a"
|
|
).goal == "goal-a"
|
|
assert webui_goals.goal_state_snapshot(
|
|
"shared-session", profile_home=tmp_path / "b"
|
|
).goal == "goal-b"
|
|
assert get_hermes_home() == original_home
|
|
|
|
|
|
def test_profile_goal_falls_back_when_session_db_path_is_frozen(monkeypatch, tmp_path):
|
|
"""A context API alone cannot make an import-time SessionDB path profile-safe."""
|
|
from api import goals as webui_goals
|
|
native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed")
|
|
# Ensure the agent dir is on sys.path before importing the sibling top-level
|
|
# `hermes_state` module. importorskip("hermes_cli.goals") can succeed from a
|
|
# cached sys.modules entry even after a prior shard test stripped the agent
|
|
# dir from sys.path, but the bare `import hermes_state` below is uncached and
|
|
# would then fail with ModuleNotFoundError under full-suite ordering (every
|
|
# other test in this file already does this prepend).
|
|
monkeypatch.syspath_prepend(str(Path(native_goals.__file__).resolve().parents[1]))
|
|
import hermes_state
|
|
|
|
frozen_db_path = tmp_path / "frozen-home" / "state.db"
|
|
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", frozen_db_path)
|
|
webui_goals._DB_CACHE.clear()
|
|
native_goals._DB_CACHE.clear()
|
|
|
|
profile_a = tmp_path / "profile-a"
|
|
profile_b = tmp_path / "profile-b"
|
|
try:
|
|
assert webui_goals.goal_command_payload(
|
|
"shared-session", "goal-a", profile_home=profile_a
|
|
)["ok"] is True
|
|
assert webui_goals.goal_command_payload(
|
|
"shared-session", "goal-b", profile_home=profile_b
|
|
)["ok"] is True
|
|
|
|
assert webui_goals.goal_state_snapshot(
|
|
"shared-session", profile_home=profile_a
|
|
).goal == "goal-a"
|
|
assert webui_goals.goal_state_snapshot(
|
|
"shared-session", profile_home=profile_b
|
|
).goal == "goal-b"
|
|
finally:
|
|
for cache in (webui_goals._DB_CACHE, native_goals._DB_CACHE):
|
|
for db in cache.values():
|
|
close = getattr(db, "close", None)
|
|
if close is not None:
|
|
close()
|
|
cache.clear()
|
|
|
|
|
|
def test_profile_goal_context_resets_when_native_call_raises(monkeypatch, tmp_path):
|
|
"""A failed delegated call cannot leak its profile into the next task."""
|
|
from api import goals as webui_goals
|
|
native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed")
|
|
|
|
monkeypatch.syspath_prepend(str(Path(native_goals.__file__).resolve().parents[1]))
|
|
from hermes_constants import (
|
|
get_hermes_home,
|
|
reset_hermes_home_override,
|
|
set_hermes_home_override,
|
|
)
|
|
|
|
class RaisingGoalManager:
|
|
state = None
|
|
|
|
def __init__(self, session_id, default_max_turns=20):
|
|
self.session_id = session_id
|
|
self.default_max_turns = default_max_turns
|
|
|
|
def explode(self):
|
|
raise RuntimeError("boom")
|
|
|
|
original_home = get_hermes_home()
|
|
monkeypatch.setattr(webui_goals, "_NativeGoalManager", RaisingGoalManager)
|
|
manager = webui_goals._ProfileGoalManager(
|
|
"sid-context-reset",
|
|
profile_home=tmp_path / "profile",
|
|
context_api=(set_hermes_home_override, reset_hermes_home_override),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="boom"):
|
|
manager.explode()
|
|
|
|
assert get_hermes_home() == original_home
|
|
|
|
|
|
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_key"] == "goal_continuing"
|
|
assert decision["message_args"] == [1, 20, "one step remains"]
|
|
assert decision["message"].startswith("↻ Continuing toward goal")
|
|
assert decision["should_continue"] is True
|
|
assert decision["continuation_prompt"].startswith("[Continuing toward your standing goal]")
|
|
|
|
|
|
@pytest.mark.parametrize("gateway_owned", [False, True])
|
|
def test_goal_endpoint_sets_goal_and_starts_kickoff_stream(
|
|
monkeypatch, tmp_path, gateway_owned
|
|
):
|
|
"""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, **_kw: tmp_path)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"webui_gateway_chat_enabled",
|
|
lambda _cfg: gateway_owned,
|
|
)
|
|
monkeypatch.setattr(routes, "get_config", lambda: {})
|
|
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"
|
|
assert started[0]["external_runtime_owned"] is gateway_owned
|
|
|
|
|
|
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_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, **_kw: 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
|
|
assert "goal_command_payload" in ROUTES_PY
|
|
assert "kickoff_prompt" in ROUTES_PY
|
|
assert "_start_chat_stream_for_session" in ROUTES_PY
|
|
|
|
|
|
def test_chat_start_forwards_goal_related_to_gateway_worker(monkeypatch, tmp_path):
|
|
from api import routes
|
|
import api.turn_journal as turn_journal
|
|
|
|
class FakeSession:
|
|
session_id = "sid-goal-related-gateway"
|
|
active_stream_id = None
|
|
pending_started_at = 0.0
|
|
title = "Goal Chat"
|
|
profile = "default"
|
|
|
|
captured = {}
|
|
|
|
class FakeThread:
|
|
def __init__(self, *, target, args, kwargs, daemon):
|
|
captured["target"] = target
|
|
captured["args"] = args
|
|
captured["kwargs"] = kwargs
|
|
captured["daemon"] = daemon
|
|
|
|
def start(self):
|
|
captured["started"] = True
|
|
|
|
def fake_prepare(session, **kwargs):
|
|
session.pending_started_at = 123.0
|
|
session.title = "Goal Chat"
|
|
|
|
monkeypatch.setattr(routes, "_get_session_agent_lock", lambda *args, **kwargs: threading.Lock())
|
|
monkeypatch.setattr(routes, "_active_stream_blocks_chat_start", lambda *args, **kwargs: False)
|
|
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", fake_prepare)
|
|
monkeypatch.setattr(routes, "_is_hidden_empty_session", lambda *args, **kwargs: False)
|
|
monkeypatch.setattr(routes, "publish_session_list_changed", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(routes, "set_last_workspace", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(turn_journal, "append_turn_journal_event", lambda *args, **kwargs: {})
|
|
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda *args, **kwargs: True)
|
|
monkeypatch.setattr(routes.threading, "Thread", FakeThread)
|
|
monkeypatch.setattr(routes.uuid, "uuid4", lambda: SimpleNamespace(hex="goal-stream-id"))
|
|
|
|
response = routes._start_chat_stream_for_session(
|
|
FakeSession(),
|
|
msg="continue the goal",
|
|
attachments=[],
|
|
workspace=str(tmp_path),
|
|
model="gpt-5.5",
|
|
model_provider="openai-codex",
|
|
goal_related=True,
|
|
)
|
|
|
|
assert response["stream_id"] == "goal-stream-id"
|
|
assert captured["target"] is routes._run_gateway_chat_streaming
|
|
assert captured["kwargs"]["goal_related"] is True
|
|
assert captured["kwargs"]["model_provider"] == "openai-codex"
|
|
assert captured["started"] is True
|
|
|
|
|
|
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
|
|
# #6962 added 'stop' to this busy-command passthrough list so a busy /stop
|
|
# cancels immediately. Assert /goal's membership rather than the exact list
|
|
# literal, so adding another passthrough command does not fail this test.
|
|
_passthrough = re.search(
|
|
r"if\(_pc&&\[([^\]]*)\]\.includes\(_pc\.name\)\)", MESSAGES_JS
|
|
)
|
|
assert _passthrough, "busy-command passthrough list not found in messages.js"
|
|
assert "'goal'" in _passthrough.group(1)
|
|
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 "t('goal_evaluating_progress')" in MESSAGES_JS
|
|
assert "if(goalState==='evaluating')" in MESSAGES_JS
|
|
assert "setComposerStatus(goalEvaluatingMessage);" in MESSAGES_JS
|
|
assert "return;" in MESSAGES_JS
|
|
|
|
|
|
def test_goal_kickoff_forwards_explicit_model_pick_to_resolver(monkeypatch, tmp_path):
|
|
"""#6703: /api/goal must carry explicit_model_pick to the model resolver.
|
|
|
|
Without it a persisted cross-provider pick (e.g. provider1:model1 chosen
|
|
for the session) is treated as stale by _resolve_compatible_session_model_state
|
|
and "repaired" back to the profile default mid-session, switching the
|
|
provider while /goal runs.
|
|
"""
|
|
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-explicit"
|
|
profile = "default"
|
|
workspace = str(tmp_path)
|
|
model = "deepseek/deepseek-v4-flash"
|
|
model_provider = "deepseek"
|
|
messages = []
|
|
context_messages = []
|
|
pending_user_message = None
|
|
active_stream_id = None
|
|
|
|
resolver_kwargs = {}
|
|
|
|
def fake_resolve(model, provider, **kwargs):
|
|
resolver_kwargs.update(kwargs)
|
|
return model, provider, False
|
|
|
|
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
|
|
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
|
|
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path)
|
|
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False)
|
|
monkeypatch.setattr(routes, "get_config", lambda: {})
|
|
monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve)
|
|
|
|
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-explicit",
|
|
"args": "ship the feature",
|
|
"workspace": str(tmp_path),
|
|
"model": "deepseek/deepseek-v4-flash",
|
|
"model_provider": "deepseek",
|
|
"explicit_model_pick": True,
|
|
},
|
|
)
|
|
|
|
assert result["status"] == 200
|
|
assert started and started[0]["model"] == "deepseek/deepseek-v4-flash"
|
|
assert started[0]["model_provider"] == "deepseek"
|
|
# The explicit-pick marker must reach the resolver so the persisted
|
|
# cross-provider model is honored instead of reverted to the default.
|
|
assert resolver_kwargs.get("explicit_model_pick") is True
|
|
|
|
|
|
def test_goal_kickoff_defaults_explicit_model_pick_false(monkeypatch, tmp_path):
|
|
"""#6703: without the marker the resolver receives explicit_model_pick=False."""
|
|
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-default"
|
|
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
|
|
|
|
resolver_kwargs = {}
|
|
|
|
def fake_resolve(model, provider, **kwargs):
|
|
resolver_kwargs.update(kwargs)
|
|
return model, provider, False
|
|
|
|
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
|
|
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
|
|
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path)
|
|
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False)
|
|
monkeypatch.setattr(routes, "get_config", lambda: {})
|
|
monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve)
|
|
|
|
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-default",
|
|
"args": "ship the feature",
|
|
"workspace": str(tmp_path),
|
|
"model": "gpt-5.5",
|
|
"model_provider": "openai-codex",
|
|
},
|
|
)
|
|
|
|
assert result["status"] == 200
|
|
assert resolver_kwargs.get("explicit_model_pick") is False
|
|
|
|
|
|
def test_frontend_goal_sends_explicit_model_pick():
|
|
"""#6703: cmdGoal must include explicit_model_pick in the /api/goal payload.
|
|
|
|
Mirrors the chat/start marker (messages.js) so the server honors a
|
|
persisted cross-provider session pick instead of reverting to the default.
|
|
"""
|
|
goal_idx = COMMANDS_JS.find("function cmdGoal")
|
|
assert goal_idx != -1
|
|
goal_fn = COMMANDS_JS[goal_idx : COMMANDS_JS.find("\n}", goal_idx)]
|
|
assert "explicit_model_pick:_explicitPick" in goal_fn
|
|
assert "api('/api/goal'" in goal_fn
|
|
assert "_isCrossProviderPick" in goal_fn
|
|
assert "_readPendingSessionModel" in goal_fn
|
|
|
|
|
|
def test_goal_kickoff_stamps_explicit_pick_signature(monkeypatch, tmp_path):
|
|
"""#6703: /api/goal must stamp model_explicit_pick_signature on explicit picks.
|
|
|
|
Parity with /api/chat/start (#5979): the streaming resolver preserves a
|
|
custom-proxy vendor namespace on a cold catalog only when the persisted
|
|
signature matches the current routing context. Without the stamp, a first
|
|
/goal launch after a deliberate custom-provider pick loses the vendor
|
|
namespace and the provider reverts to the profile default mid-session.
|
|
"""
|
|
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-sig"
|
|
profile = "default"
|
|
workspace = str(tmp_path)
|
|
model = "deepseek/deepseek-v4-flash"
|
|
model_provider = "deepseek"
|
|
messages = []
|
|
context_messages = []
|
|
pending_user_message = None
|
|
active_stream_id = None
|
|
model_explicit_pick_signature = None
|
|
|
|
def fake_resolve(model, provider, **kwargs):
|
|
return model, provider, False
|
|
|
|
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
|
|
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
|
|
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path)
|
|
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False)
|
|
monkeypatch.setattr(routes, "get_config", lambda: {})
|
|
monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve)
|
|
|
|
def fake_start(session, **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})
|
|
|
|
session = FakeSession()
|
|
monkeypatch.setattr(routes, "get_session", lambda sid: session)
|
|
|
|
result = routes._handle_goal_command(
|
|
object(),
|
|
{
|
|
"session_id": "sid-goal-sig",
|
|
"args": "ship the feature",
|
|
"workspace": str(tmp_path),
|
|
"model": "deepseek/deepseek-v4-flash",
|
|
"model_provider": "deepseek",
|
|
"explicit_model_pick": True,
|
|
},
|
|
)
|
|
|
|
assert result["status"] == 200
|
|
from api.models import model_explicit_pick_signature as _mk_sig
|
|
|
|
assert session.model_explicit_pick_signature == _mk_sig("deepseek/deepseek-v4-flash", "deepseek")
|
|
|
|
|
|
def test_goal_kickoff_does_not_stamp_signature_without_explicit_pick(monkeypatch, tmp_path):
|
|
"""#6703: without the marker no signature is stamped (parity with chat/start)."""
|
|
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-nosig"
|
|
profile = "default"
|
|
workspace = str(tmp_path)
|
|
model = "deepseek/deepseek-v4-flash"
|
|
model_provider = "deepseek"
|
|
messages = []
|
|
context_messages = []
|
|
pending_user_message = None
|
|
active_stream_id = None
|
|
model_explicit_pick_signature = "stale-previous-sig"
|
|
|
|
def fake_resolve(model, provider, **kwargs):
|
|
return model, provider, False
|
|
|
|
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
|
|
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path)
|
|
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False)
|
|
monkeypatch.setattr(routes, "get_config", lambda: {})
|
|
monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve)
|
|
|
|
def fake_start(session, **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})
|
|
|
|
session = FakeSession()
|
|
monkeypatch.setattr(routes, "get_session", lambda sid: session)
|
|
|
|
result = routes._handle_goal_command(
|
|
object(),
|
|
{
|
|
"session_id": "sid-goal-nosig",
|
|
"args": "ship the feature",
|
|
"workspace": str(tmp_path),
|
|
"model": "deepseek/deepseek-v4-flash",
|
|
"model_provider": "deepseek",
|
|
},
|
|
)
|
|
|
|
assert result["status"] == 200
|
|
# A non-explicit kickoff leaves any prior signature untouched (chat-start parity).
|
|
assert session.model_explicit_pick_signature == "stale-previous-sig"
|
|
|
|
|
|
def test_frontend_goal_consumes_pending_model_marker():
|
|
"""#6703/#6705: cmdGoal must consume the one-shot pending session-model marker.
|
|
|
|
Mirrors the chat/start path (messages.js) so a later /goal with an unchanged
|
|
dropdown isn't treated as an explicit pick. The consume-clear is deferred to
|
|
AFTER a successful kickoff (r.stream_id) and gated on a re-read of the stored
|
|
marker that still matches the captured model/provider (#6705), so a control
|
|
command (e.g. /goal status, which never produces a stream_id) leaves the
|
|
marker intact for the next real send.
|
|
"""
|
|
goal_idx = COMMANDS_JS.find("function cmdGoal")
|
|
assert goal_idx != -1
|
|
goal_fn = COMMANDS_JS[goal_idx : COMMANDS_JS.find("\n}", goal_idx)]
|
|
assert "_clearPendingSessionModel(activeSid)" in goal_fn
|
|
assert "_pendingPickMatch && typeof _readPendingSessionModel==='function' && typeof _clearPendingSessionModel==='function'" in goal_fn
|
|
# The consume-clear must come AFTER the kickoff guard: a control command
|
|
# (no stream_id) returns before reaching it.
|
|
assert goal_fn.index("_clearPendingSessionModel(activeSid)") > goal_fn.index("if(!r||!r.stream_id)return;")
|
|
# Re-check: the marker is re-read and only cleared while it still matches
|
|
# the captured model/provider.
|
|
assert "_stillPending.model===_goalModel" in goal_fn
|
|
assert "_stillPending.model_provider" in goal_fn
|
|
|
|
|
|
def test_frontend_goal_control_command_keeps_pending_marker():
|
|
"""#6705: a control-only /goal (e.g. `/goal status`) must NOT consume the
|
|
pending explicit-pick marker.
|
|
|
|
The server skips model resolution for control commands (api/routes.py
|
|
will_kickoff excludes status/pause/resume/clear/stop/done), so clearing the
|
|
marker before the request would drop the explicit pick without using it and
|
|
the next real send would revert to the default model. The marker must
|
|
survive the control round-trip and still be read for the next kickoff.
|
|
"""
|
|
goal_idx = COMMANDS_JS.find("function cmdGoal")
|
|
assert goal_idx != -1
|
|
goal_fn = COMMANDS_JS[goal_idx : COMMANDS_JS.find("\n}", goal_idx)]
|
|
|
|
# 1) No clear may run before the /api/goal request: the marker must
|
|
# survive a control command.
|
|
pre_request = goal_fn.split("const r=await api('/api/goal'")[0]
|
|
assert "_clearPendingSessionModel" not in pre_request, (
|
|
"the pending explicit-pick marker must NOT be cleared before /api/goal — "
|
|
"a control command like /goal status never kicks off, so a pre-request "
|
|
"clear would drop the pick without using it (#6705)"
|
|
)
|
|
|
|
# 2) The consume-clear sits after the kickoff guard (r.stream_id present).
|
|
post_request = goal_fn.split("const r=await api('/api/goal'")[1]
|
|
assert "if(!r||!r.stream_id)return;" in post_request
|
|
assert "_clearPendingSessionModel(activeSid)" in post_request
|
|
assert post_request.index("if(!r||!r.stream_id)return;") < post_request.index("_clearPendingSessionModel(activeSid)")
|
|
|
|
# 3) The marker is re-read and only cleared while it still matches the
|
|
# captured model/provider.
|
|
assert "_stillPending=_readPendingSessionModel(activeSid)" in post_request
|
|
assert re.search(r"_stillPending\.model\s*===\s*_goalModel", post_request)
|
|
assert re.search(r"_stillPending\.model_provider", post_request)
|
|
assert re.search(r"_clearPendingSessionModel\(activeSid\)", post_request)
|