diff --git a/api/goals.py b/api/goals.py index 4475b10d..32d0262d 100644 --- a/api/goals.py +++ b/api/goals.py @@ -413,6 +413,25 @@ def goal_command_payload( ) +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, diff --git a/api/streaming.py b/api/streaming.py index 1a57616e..da52b131 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -3106,35 +3106,44 @@ def _run_agent_streaming( # 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 + from api.goals import evaluate_goal_after_turn, has_active_goal - _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 - _goal_decision = evaluate_goal_after_turn( - session_id, - _last_goal_response, - user_initiated=True, - profile_home=_profile_home, - ) + 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, }) diff --git a/docs/pr-media/1866/goal-evaluating-status.png b/docs/pr-media/1866/goal-evaluating-status.png new file mode 100644 index 00000000..a411a43a Binary files /dev/null and b/docs/pr-media/1866/goal-evaluating-status.png differ diff --git a/static/messages.js b/static/messages.js index bfa881ad..42c9e572 100644 --- a/static/messages.js +++ b/static/messages.js @@ -885,9 +885,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ 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}; + _latestGoalStatus={message:msg,decision:d.decision||null,state:goalState||null}; setComposerStatus(msg); showToast(msg.split('\n')[0],2600); }catch(_){} diff --git a/tests/test_goal_command_webui.py b/tests/test_goal_command_webui.py index 45ffd55d..e1bca59b 100644 --- a/tests/test_goal_command_webui.py +++ b/tests/test_goal_command_webui.py @@ -100,6 +100,25 @@ def test_goal_command_payload_rejects_new_goal_while_stream_running(monkeypatch) 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 @@ -221,6 +240,17 @@ def test_streaming_post_turn_goal_hook_surfaces_and_continues(): 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 @@ -232,3 +262,11 @@ def test_frontend_has_goal_slash_command_and_status_event_handler(): 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