Merge remote-tracking branch 'origin/master' into stage-331
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
22991fa820
11 changed files with 978 additions and 475 deletions
33
CHANGELOG.md
33
CHANGELOG.md
|
|
@ -27,6 +27,39 @@
|
|||
- `api/routes.py` (4 PRs touched it) and `api/streaming.py` (2 PRs) were the multi-PR files. All hunks at distinct anchors; stage merge clean with no conflicts.
|
||||
- Theme coherence: every PR in this batch addresses session compression, lineage, or continuation-stitching — the same conceptual surface from different angles.
|
||||
|
||||
## [v0.51.36] — 2026-05-10 — Release L (locale + provider + cross-cutting)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #1992** by @29n — `ctl.sh` line 42 used `[[ -v ${key} ]]`, which requires bash 4.2+. macOS ships with bash 3.2 → `conditional binary operator expected` error. Replaced with `[[ -n "${!key+x}" ]]` — a portable variable-set check that works on bash 3.2+, zsh, and POSIX-compatible shells. No behavior change.
|
||||
|
||||
- **PR #1998** by @franksong2702 — Localized `/goal` runtime status strings. Added 13 i18n keys (`goal_evaluating_progress`, `goal_working_toward`, `goal_continuing_toast`, `goal_status_*`, `goal_set/paused/resumed/cleared/no_goal`, `goal_achieved`, `goal_paused_budget_exhausted`, `goal_continuing`) across all locales; new keys reach `static/messages.js` and `static/commands.js` so the goal UI no longer hardcodes English. Closes #1933.
|
||||
|
||||
- **PR #2000** by @qxxaa — Skill tools resolve from the wrong profile after per-request profile switch. `tools/skills_tool.py` and `tools/skill_manager_tool.py` cache `HERMES_HOME` as a module-level constant at import time. The process-wide `switch_profile()` path patches both modules via `_set_hermes_home()`, but the per-request path (`switch_profile(process_wide=False)`, introduced in #1700) only updated `os.environ['HERMES_HOME']` and skipped the module patching. Result: agents on non-default profiles always saw the root profile's skills. Fix adds the same monkeypatching to the per-request branch in `api/streaming.py`. Closes the parity gap with #1700.
|
||||
|
||||
- **PR #2001** by @franksong2702 — `clarify.timeout` config was ignored by WebUI clarify prompts. The callback used a hardcoded `timeout = 120`. Now reads `clarify.timeout` from `api.config.get_config()` with bounded fallback (defaults to 120 on missing/invalid config), and threads `timeout_seconds` into the `api.clarify.submit_pending` payload so the frontend countdown matches the backend timeout. Regression test in `tests/test_sprint42.py`. Closes #1999.
|
||||
|
||||
- **PR #2005** by @vikarag — Added Xiaomi as a first-class provider in the WebUI's model catalog. `hermes-agent` already registered Xiaomi (verified at `hermes_cli/models.py:782` + auth entries) but `api/config.py` was missing the corresponding `_PROVIDER_DISPLAY` / `_PROVIDER_ALIASES` / `_PROVIDER_MODELS` entries, so the provider list showed Xiaomi as `Unsupported` and the model dropdown fell back to OpenRouter. Adds `xiaomi` display name, `mimo`/`xiaomi-mimo` aliases, and 5 MiMo models (V2.5 Pro/V2.5/V2 Pro/V2 Omni/V2 Flash).
|
||||
|
||||
### i18n
|
||||
|
||||
- **PR #2002** by @eov128 — Refreshed Simplified Chinese (zh) translation. Two kinds of changes:
|
||||
- Decoded `\uXXXX` escape sequences to literal CJK characters in already-translated strings (semantically identical at runtime; improves source readability and grep-ability)
|
||||
- Translated 30+ previously-untranslated strings tagged `// TODO: translate` — covering MCP server status (`mcp_status_active`, `mcp_status_configured`, ...), MCP tools panel, session toolsets, workspace hidden files, terminal pane, and personality switch hint
|
||||
|
||||
**Stage 330 conflict resolution:** #1998 added new `goal_*` English keys interleaved with the `cmd_interrupt` block that #2002 was rewriting; resolved by preserving #1998's new English keys (TODO: translate) above the section while taking #2002's CJK literals for `cmd_*` / `settings_*` keys.
|
||||
|
||||
**Stage 330 test fix:** `tests/test_chinese_locale.py::test_chinese_locale_includes_representative_translations` was pinned to the source-encoded `\uXXXX` form for `settings_title` and `login_title`. Broadened to accept either `\uXXXX` or literal CJK (same runtime behavior). Other source-form assertions in this test were already on literal CJK.
|
||||
|
||||
### Tests
|
||||
|
||||
5049 → **5049 collected, 5049 passing, 0 regressions** (one PR added new tests in `test_kanban_ui_static.py` already counted in stage 329; stage 330 net is flat). Full suite 158s on Python 3.11 with `HERMES_HOME` isolation.
|
||||
|
||||
### Notes
|
||||
|
||||
- `api/streaming.py` was the high-collision file (4 PRs touched it: #1998 #2000 #2001 #2006-not-in-this-stage). Stage merge clean; #2000 and #2001 each added separate ~17-LOC blocks at distinct anchor points, no overlap.
|
||||
- All 6 PRs from 6 different authors except for #1998+#2001 (both @franksong2702). Disjoint themes.
|
||||
|
||||
## [v0.51.35] — 2026-05-10 — Release K (kanban polish + i18n DE pluralization)
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -657,6 +657,7 @@ _PROVIDER_DISPLAY = {
|
|||
"qwen": "Qwen",
|
||||
"x-ai": "xAI",
|
||||
"nvidia": "NVIDIA NIM",
|
||||
"xiaomi": "Xiaomi",
|
||||
}
|
||||
|
||||
# Provider alias → canonical slug. Users configure providers using the
|
||||
|
|
@ -707,6 +708,8 @@ _PROVIDER_ALIASES = {
|
|||
"nvidia-nim": "nvidia",
|
||||
"build-nvidia": "nvidia",
|
||||
"nemotron": "nvidia",
|
||||
"mimo": "xiaomi",
|
||||
"xiaomi-mimo": "xiaomi",
|
||||
# Legacy alias — earlier WebUI builds wrote ``provider: local`` for unknown
|
||||
# loopback endpoints, but ``local`` is not registered in
|
||||
# ``hermes_cli.auth.PROVIDER_REGISTRY``. Routing it through ``custom``
|
||||
|
|
@ -1066,6 +1069,14 @@ _PROVIDER_MODELS = {
|
|||
{"id": "nvidia/llama-3.3-nemotron-super-49b-v1.5", "label": "Llama 3.3 Nemotron Super 49B"},
|
||||
{"id": "qwen/qwen3-next-80b-a3b-instruct", "label": "Qwen3 Next 80B"},
|
||||
],
|
||||
# Xiaomi MiMo — direct API via api.xiaomimimo.com
|
||||
"xiaomi": [
|
||||
{"id": "mimo-v2.5-pro", "label": "MiMo V2.5 Pro"},
|
||||
{"id": "mimo-v2.5", "label": "MiMo V2.5"},
|
||||
{"id": "mimo-v2-pro", "label": "MiMo V2 Pro"},
|
||||
{"id": "mimo-v2-omni", "label": "MiMo V2 Omni"},
|
||||
{"id": "mimo-v2-flash", "label": "MiMo V2 Flash"},
|
||||
],
|
||||
# xAI — prefix used in OpenRouter model IDs (x-ai/grok-4-20)
|
||||
"x-ai": [
|
||||
{"id": "grok-4.20", "label": "Grok 4.20"},
|
||||
|
|
|
|||
127
api/goals.py
127
api/goals.py
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import copy
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
|
@ -279,6 +280,8 @@ def _payload(
|
|||
error: str | None = None,
|
||||
kickoff_prompt: str | None = None,
|
||||
decision: Dict[str, Any] | None = None,
|
||||
message_key: str | None = None,
|
||||
message_args: list[Any] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
body: Dict[str, Any] = {
|
||||
"ok": bool(ok),
|
||||
|
|
@ -292,9 +295,98 @@ def _payload(
|
|||
body["kickoff_prompt"] = kickoff_prompt
|
||||
if decision is not None:
|
||||
body["decision"] = decision
|
||||
if message_key:
|
||||
body["message_key"] = message_key
|
||||
if message_args is not None:
|
||||
body["message_args"] = [a for a in message_args if a is not None]
|
||||
return body
|
||||
|
||||
|
||||
def _goal_status_payload(state: Any, *, default_message: str | None = None) -> Dict[str, Any]:
|
||||
"""Build localized-status style payload fields from a goal state."""
|
||||
if default_message is None:
|
||||
default_message = "No active goal. Set one with /goal <text>."
|
||||
if state is None:
|
||||
return {"message": default_message, "message_key": "goal_status_none"}
|
||||
status = str(getattr(state, "status", "") or "").strip()
|
||||
if status in ("cleared",):
|
||||
return {"message": default_message, "message_key": "goal_status_none"}
|
||||
turns_used = int(getattr(state, "turns_used", 0) or 0)
|
||||
max_turns = int(getattr(state, "max_turns", 0) or 0)
|
||||
goal = str(getattr(state, "goal", "") or "")
|
||||
if status == "active":
|
||||
return {
|
||||
"message": f"⊙ Goal (active, {turns_used}/{max_turns} turns): {goal}",
|
||||
"message_key": "goal_status_active",
|
||||
"message_args": [turns_used, max_turns, goal],
|
||||
}
|
||||
if status == "paused":
|
||||
reason = str(getattr(state, "paused_reason", "") or "")
|
||||
return {
|
||||
"message": f"⏸ Goal (paused, {turns_used}/{max_turns}{' — ' + reason if reason else ''}): {goal}",
|
||||
"message_key": "goal_status_paused",
|
||||
"message_args": [turns_used, max_turns, reason, goal],
|
||||
}
|
||||
if status == "done":
|
||||
return {
|
||||
"message": f"✓ Goal done ({turns_used}/{max_turns}): {goal}",
|
||||
"message_key": "goal_status_done",
|
||||
"message_args": [turns_used, max_turns, goal],
|
||||
}
|
||||
return {
|
||||
"message": f"Goal ({status}, {turns_used}/{max_turns}): {goal}",
|
||||
"message_args": [status, turns_used, max_turns, goal],
|
||||
}
|
||||
|
||||
|
||||
def _extract_goal_turns_from_message(message: str) -> tuple[int, int]:
|
||||
"""Best-effort extraction for continuation messages like '(1/20)'."""
|
||||
if not message:
|
||||
return 0, 0
|
||||
match = re.search(r"\((\d+)\s*/\s*(\d+)\)", message)
|
||||
if not match:
|
||||
return 0, 0
|
||||
try:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
except Exception:
|
||||
return 0, 0
|
||||
|
||||
|
||||
def _goal_decision_payload(
|
||||
decision: Dict[str, Any],
|
||||
state: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Attach goal message i18n key/args to an evaluation decision."""
|
||||
if not isinstance(decision, dict):
|
||||
return decision
|
||||
status = str(decision.get("status") or "").strip()
|
||||
reason = str(decision.get("reason") or "").strip()
|
||||
turns_used = int(getattr(state, "turns_used", 0) or 0)
|
||||
max_turns = int(getattr(state, "max_turns", 0) or 0)
|
||||
if (turns_used, max_turns) == (0, 0):
|
||||
turns_used, max_turns = _extract_goal_turns_from_message(str(decision.get("message") or ""))
|
||||
|
||||
if status == "done":
|
||||
return {
|
||||
**decision,
|
||||
"message_key": "goal_achieved",
|
||||
"message_args": [reason],
|
||||
}
|
||||
if status == "paused":
|
||||
return {
|
||||
**decision,
|
||||
"message_key": "goal_paused_budget_exhausted",
|
||||
"message_args": [turns_used, max_turns],
|
||||
}
|
||||
if decision.get("should_continue"):
|
||||
return {
|
||||
**decision,
|
||||
"message_key": "goal_continuing",
|
||||
"message_args": [turns_used, max_turns, reason],
|
||||
}
|
||||
return decision
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -355,24 +447,46 @@ def goal_command_payload(
|
|||
lower = text.lower()
|
||||
|
||||
if not text or lower == "status":
|
||||
return _payload(action="status", message=mgr.status_line(), state=getattr(mgr, "state", None))
|
||||
state = getattr(mgr, "state", None)
|
||||
status_payload = _goal_status_payload(state)
|
||||
return _payload(action="status", state=state, **status_payload)
|
||||
|
||||
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)
|
||||
return _payload(
|
||||
ok=False,
|
||||
action="pause",
|
||||
error="no_goal",
|
||||
message="No goal set.",
|
||||
message_key="goal_no_goal",
|
||||
)
|
||||
return _payload(
|
||||
action="pause",
|
||||
message=f"⏸ Goal paused: {state.goal}",
|
||||
message_key="goal_paused",
|
||||
message_args=[str(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(
|
||||
ok=False,
|
||||
action="resume",
|
||||
error="no_goal",
|
||||
message="No goal to resume.",
|
||||
message_key="goal_no_goal",
|
||||
)
|
||||
return _payload(
|
||||
action="resume",
|
||||
message=(
|
||||
f"▶ Goal resumed: {state.goal}\n"
|
||||
"Send a new message, or type continue, to kick it off."
|
||||
),
|
||||
message_key="goal_resumed",
|
||||
message_args=[str(state.goal)],
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
|
@ -382,6 +496,7 @@ def goal_command_payload(
|
|||
return _payload(
|
||||
action="clear",
|
||||
message="Goal cleared." if had else "No active goal.",
|
||||
message_key="goal_cleared" if had else "goal_no_goal",
|
||||
state=getattr(mgr, "state", None),
|
||||
)
|
||||
|
||||
|
|
@ -408,6 +523,8 @@ def goal_command_payload(
|
|||
"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"
|
||||
),
|
||||
message_key="goal_set",
|
||||
message_args=[state.max_turns, state.goal],
|
||||
state=state,
|
||||
kickoff_prompt=state.goal,
|
||||
)
|
||||
|
|
@ -486,4 +603,6 @@ def evaluate_goal_after_turn(
|
|||
decision.setdefault("should_continue", False)
|
||||
decision.setdefault("continuation_prompt", None)
|
||||
decision.setdefault("message", "")
|
||||
decision = dict(decision)
|
||||
decision = _goal_decision_payload(decision, getattr(mgr, "state", None))
|
||||
return decision
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from typing import Optional
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
from api.config import (
|
||||
get_config,
|
||||
STREAMS, STREAMS_LOCK, CANCEL_FLAGS, AGENT_INSTANCES, STREAM_PARTIAL_TEXT,
|
||||
STREAM_REASONING_TEXT, STREAM_LIVE_TOOL_CALLS,
|
||||
STREAM_GOAL_RELATED, PENDING_GOAL_CONTINUATION,
|
||||
|
|
@ -86,6 +87,19 @@ def _is_quota_error_text(err_text: str) -> bool:
|
|||
)
|
||||
|
||||
|
||||
def _clarify_timeout_seconds(default: int = 120) -> int:
|
||||
"""Resolve clarify timeout from config, with bounded fallback."""
|
||||
try:
|
||||
cfg = get_config()
|
||||
raw = cfg.get("clarify", {}).get("timeout", default)
|
||||
timeout_seconds = int(raw)
|
||||
if timeout_seconds <= 0:
|
||||
return default
|
||||
return timeout_seconds
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _classify_provider_error(err_str: str, exc=None, *, silent_failure: bool = False) -> dict:
|
||||
"""Classify provider/agent failure text for WebUI apperror UX.
|
||||
|
||||
|
|
@ -2144,6 +2158,23 @@ def _run_agent_streaming(
|
|||
os.environ['HERMES_SESSION_KEY'] = session_id
|
||||
if _profile_home:
|
||||
os.environ['HERMES_HOME'] = _profile_home
|
||||
# Patch module-level caches to match the active profile.
|
||||
# _set_hermes_home() does this for process-wide switches
|
||||
# but per-request switches skip it (#1700).
|
||||
from pathlib import Path as _P
|
||||
_ph = _P(_profile_home)
|
||||
try:
|
||||
import tools.skills_tool as _sk
|
||||
_sk.HERMES_HOME = _ph
|
||||
_sk.SKILLS_DIR = _ph / 'skills'
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
try:
|
||||
import tools.skill_manager_tool as _sm
|
||||
_sm.HERMES_HOME = _ph
|
||||
_sm.SKILLS_DIR = _ph / 'skills'
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
# Lock released — agent runs without holding it
|
||||
# ── MCP Server Discovery (lazy import, idempotent) ──
|
||||
# MUST run AFTER the HERMES_HOME mutation above — `discover_mcp_tools()`
|
||||
|
|
@ -2204,7 +2235,7 @@ def _run_agent_streaming(
|
|||
|
||||
def _clarify_callback_impl(question, choices, sid, cancel_evt, put_event):
|
||||
"""Bridge Hermes clarify prompts to the WebUI."""
|
||||
timeout = 120
|
||||
timeout = _clarify_timeout_seconds()
|
||||
choices_list = [str(choice) for choice in (choices or [])]
|
||||
data = {
|
||||
'question': str(question or ''),
|
||||
|
|
@ -2212,6 +2243,7 @@ def _run_agent_streaming(
|
|||
'session_id': sid,
|
||||
'kind': 'clarify',
|
||||
'requested_at': time.time(),
|
||||
'timeout_seconds': timeout,
|
||||
}
|
||||
try:
|
||||
from api.clarify import submit_pending as _submit_clarify_pending, clear_pending as _clear_clarify_pending
|
||||
|
|
@ -3445,6 +3477,7 @@ def _run_agent_streaming(
|
|||
'session_id': session_id,
|
||||
'state': 'evaluating',
|
||||
'message': 'Evaluating goal progress…',
|
||||
'message_key': 'goal_evaluating_progress',
|
||||
})
|
||||
_goal_decision = evaluate_goal_after_turn(
|
||||
session_id,
|
||||
|
|
@ -3459,6 +3492,8 @@ def _run_agent_streaming(
|
|||
'session_id': session_id,
|
||||
'state': 'continuing' if decision.get('should_continue') else 'idle',
|
||||
'message': _goal_message,
|
||||
'message_key': decision.get('message_key') or ('goal_continuing' if _goal_message else ''),
|
||||
'message_args': decision.get('message_args') or [],
|
||||
'decision': decision,
|
||||
})
|
||||
if decision.get('should_continue'):
|
||||
|
|
@ -3472,6 +3507,8 @@ def _run_agent_streaming(
|
|||
'continuation_prompt': continuation_prompt,
|
||||
'text': continuation_prompt,
|
||||
'message': _goal_message,
|
||||
'message_key': decision.get('message_key') or 'goal_continuing',
|
||||
'message_args': decision.get('message_args') or [],
|
||||
'decision': decision,
|
||||
})
|
||||
except Exception as _goal_exc:
|
||||
|
|
|
|||
4
ctl.sh
4
ctl.sh
|
|
@ -39,7 +39,7 @@ _load_repo_dotenv_preserving_env() {
|
|||
key="${key#export }"
|
||||
key="${key//[[:space:]]/}"
|
||||
[[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
||||
if [[ -v ${key} ]]; then
|
||||
if [[ -n "${!key+x}" ]]; then
|
||||
value="${!key}"
|
||||
preserved+=("${key}=${value}")
|
||||
fi
|
||||
|
|
@ -215,7 +215,7 @@ start_cmd() {
|
|||
: >> "${LOG_FILE}"
|
||||
(
|
||||
cd "${REPO_ROOT}"
|
||||
exec "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" "${CTL_BOOTSTRAP_ARGS[@]}"
|
||||
exec "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" ${CTL_BOOTSTRAP_ARGS[@]+"${CTL_BOOTSTRAP_ARGS[@]}"}
|
||||
) >> "${LOG_FILE}" 2>&1 &
|
||||
pid=$!
|
||||
|
||||
|
|
|
|||
|
|
@ -639,7 +639,17 @@ async function cmdGoal(args){
|
|||
model_provider:S.session.model_provider||null,
|
||||
profile:S.activeProfile||S.session.profile||'default',
|
||||
})});
|
||||
const msg=String((r&&r.message)||'').trim();
|
||||
const msg = (() => {
|
||||
const raw = String((r && r.message) || '').trim();
|
||||
const key = String((r && r.message_key) || '').trim();
|
||||
const args = Array.isArray(r && r.message_args) ? r.message_args : [];
|
||||
if (raw.includes('\n')) return raw;
|
||||
if (key && typeof t === 'function') {
|
||||
const translated = String(t(key, ...args));
|
||||
if (translated && translated !== key) return translated;
|
||||
}
|
||||
return raw;
|
||||
})();
|
||||
if(msg){
|
||||
S.messages.push({role:'assistant',content:msg,_ts:Date.now()/1000,_goalStatus:true,_transient:true});
|
||||
renderMessages({preserveScroll:true});
|
||||
|
|
@ -649,7 +659,7 @@ async function cmdGoal(args){
|
|||
S.toolCalls=[];
|
||||
if(typeof clearLiveToolCards==='function')clearLiveToolCards();
|
||||
appendThinking();setBusy(true);
|
||||
setComposerStatus('Working toward goal…');
|
||||
setComposerStatus(t('goal_working_toward'));
|
||||
S.activeStreamId=r.stream_id;
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.session.active_stream_id=r.stream_id;
|
||||
|
|
|
|||
1037
static/i18n.js
1037
static/i18n.js
File diff suppressed because it is too large
Load diff
|
|
@ -896,17 +896,30 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||
}catch(_){}
|
||||
});
|
||||
|
||||
function _resolveGoalMessage(d){
|
||||
const key=String(d && d.message_key ? d.message_key : '').trim();
|
||||
const args=Array.isArray(d && d.message_args) ? d.message_args : [];
|
||||
const raw=String(d&&d.message||'').trim();
|
||||
if(key && typeof t==='function'){
|
||||
try{
|
||||
const translated=String(t(key,...args));
|
||||
if(translated && translated!==key)return translated;
|
||||
}catch(_){}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
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…';
|
||||
const goalEvaluatingMessage=t('goal_evaluating_progress');
|
||||
if(goalState==='evaluating'){
|
||||
setComposerStatus(goalEvaluatingMessage);
|
||||
return;
|
||||
}
|
||||
const msg=String(d.message||'').trim();
|
||||
const msg=_resolveGoalMessage(d);
|
||||
if(!msg)return;
|
||||
_latestGoalStatus={message:msg,decision:d.decision||null,state:goalState||null};
|
||||
setComposerStatus(msg);
|
||||
|
|
@ -927,7 +940,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||
model_provider:S.session&&S.session.model_provider||null,
|
||||
profile:S.activeProfile||'default',
|
||||
};
|
||||
showToast('Continuing toward goal…',2200);
|
||||
const toast=t('goal_continuing_toast');
|
||||
const cmsg=_resolveGoalMessage(d);
|
||||
showToast((toast&&cmsg&&cmsg!==toast)?cmsg.split('\n')[0]:toast,2200);
|
||||
}catch(_){}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -79,18 +79,23 @@ def test_chinese_locale_block_exists():
|
|||
|
||||
def test_chinese_locale_includes_representative_translations():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
expected = [
|
||||
"settings_title: '\\u8bbe\\u7f6e'",
|
||||
"login_title: '\\u767b\\u5f55'",
|
||||
"approval_heading: '需要审批'",
|
||||
"tab_tasks: '任务'",
|
||||
"tab_profiles: '配置'",
|
||||
"session_time_bucket_today: '今天'",
|
||||
"onboarding_title: '欢迎使用 Hermes Web UI'",
|
||||
"onboarding_complete: '引导完成'",
|
||||
# Each tuple is a list of acceptable source forms for the same translation —
|
||||
# either escape-encoded `\uXXXX` form or literal CJK characters. They produce
|
||||
# the same runtime string; do not pin source encoding.
|
||||
expected_alternatives = [
|
||||
[r"settings_title: '\u8bbe\u7f6e'", "settings_title: '设置'"],
|
||||
[r"login_title: '\u767b\u5f55'", "login_title: '登录'"],
|
||||
["approval_heading: '需要审批'"],
|
||||
["tab_tasks: '任务'"],
|
||||
["tab_profiles: '配置'"],
|
||||
["session_time_bucket_today: '今天'"],
|
||||
["onboarding_title: '欢迎使用 Hermes Web UI'"],
|
||||
["onboarding_complete: '引导完成'"],
|
||||
]
|
||||
for entry in expected:
|
||||
assert entry in src
|
||||
for alts in expected_alternatives:
|
||||
assert any(alt in src for alt in alts), (
|
||||
f"None of the expected forms found in i18n.js: {alts!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_chinese_locale_covers_english_keys():
|
||||
|
|
|
|||
|
|
@ -68,10 +68,18 @@ def test_goal_command_payload_matches_gateway_controls(monkeypatch):
|
|||
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
|
||||
|
|
@ -145,6 +153,8 @@ def test_goal_continuation_decision_emits_status_and_normal_user_prompt(monkeypa
|
|||
|
||||
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]")
|
||||
|
|
@ -266,7 +276,7 @@ def test_frontend_has_goal_slash_command_and_status_event_handler():
|
|||
|
||||
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 "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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ Covers:
|
|||
- streaming.py: SessionDB init is placed before AIAgent construction
|
||||
"""
|
||||
import ast
|
||||
import threading
|
||||
import pathlib
|
||||
import re
|
||||
import queue
|
||||
|
|
@ -402,6 +403,133 @@ class TestRuntimeRouteInjection(unittest.TestCase):
|
|||
"interim_assistant event should carry the assistant commentary text"
|
||||
)
|
||||
|
||||
def test_clarify_callback_passes_configured_timeout_seconds(self):
|
||||
"""clarify prompt data should use clarify.timeout from config when present."""
|
||||
import api.streaming as streaming
|
||||
|
||||
captured = {}
|
||||
submit_payloads = []
|
||||
|
||||
class FakeEntry:
|
||||
def __init__(self, value):
|
||||
self.result = value
|
||||
self.event = threading.Event()
|
||||
self.event.set()
|
||||
|
||||
def fake_submit_pending(_sid, payload):
|
||||
submit_payloads.append(payload)
|
||||
return FakeEntry("selected")
|
||||
|
||||
class CapturingAgent:
|
||||
def __init__(self, model=None, provider=None, base_url=None, api_key=None,
|
||||
platform=None, quiet_mode=False, enabled_toolsets=None,
|
||||
fallback_model=None, session_id=None, session_db=None,
|
||||
stream_delta_callback=None, reasoning_callback=None,
|
||||
tool_progress_callback=None, clarify_callback=None, **kwargs):
|
||||
self.clarify_callback = clarify_callback
|
||||
self.session_id = session_id
|
||||
captured["init_kwargs"] = {
|
||||
"clarify_callback": clarify_callback,
|
||||
}
|
||||
|
||||
def run_conversation(self, **kwargs):
|
||||
if self.clarify_callback:
|
||||
captured["clarify_result"] = self.clarify_callback(
|
||||
"Need user confirmation",
|
||||
["first", "second"],
|
||||
)
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "user", "content": kwargs.get("persist_user_message", "")},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
}
|
||||
|
||||
def interrupt(self, _message):
|
||||
captured["interrupted"] = True
|
||||
|
||||
class FakeSession:
|
||||
session_id = "sess-clarify-timeout"
|
||||
title = "clarify-timeout test"
|
||||
workspace = "/tmp"
|
||||
model = "gpt-5.4"
|
||||
messages = []
|
||||
personality = None
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
estimated_cost = None
|
||||
tool_calls = []
|
||||
active_stream_id = None
|
||||
pending_user_message = None
|
||||
pending_attachments = []
|
||||
pending_started_at = None
|
||||
|
||||
def save(self, touch_updated_at=True, **_kwargs):
|
||||
pass
|
||||
|
||||
def compact(self):
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"title": self.title,
|
||||
"workspace": self.workspace,
|
||||
"model": self.model,
|
||||
"created_at": 0,
|
||||
"updated_at": 0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
"project_id": None,
|
||||
"profile": None,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"estimated_cost": None,
|
||||
"personality": None,
|
||||
}
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
return "/tmp/fake.json"
|
||||
|
||||
fake_stream_id = "stream-clarify-timeout"
|
||||
fake_queue = queue.Queue()
|
||||
fake_rt_module = types.ModuleType("hermes_cli.runtime_provider")
|
||||
fake_rt_module.resolve_runtime_provider = mock.Mock(return_value={
|
||||
"provider": "openai-codex",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "rt-key",
|
||||
"api_mode": "codex_responses",
|
||||
"command": "codex",
|
||||
"args": ["exec", "--json"],
|
||||
"credential_pool": object(),
|
||||
})
|
||||
fake_hermes_cli = types.ModuleType("hermes_cli")
|
||||
fake_hermes_cli.runtime_provider = fake_rt_module
|
||||
fake_hermes_state = types.ModuleType("hermes_state")
|
||||
fake_hermes_state.SessionDB = mock.Mock(return_value=object())
|
||||
|
||||
with mock.patch.object(streaming, "get_session", return_value=FakeSession()), \
|
||||
mock.patch.object(streaming, "_get_ai_agent", return_value=CapturingAgent), \
|
||||
mock.patch.object(streaming, "resolve_model_provider", return_value=("gpt-5.4", "openai-codex", None)), \
|
||||
mock.patch.object(streaming, "get_config", return_value={"clarify": {"timeout": 300}}), \
|
||||
mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \
|
||||
mock.patch("api.clarify.submit_pending", side_effect=fake_submit_pending), \
|
||||
mock.patch.dict(sys.modules, {
|
||||
"hermes_cli": fake_hermes_cli,
|
||||
"hermes_cli.runtime_provider": fake_rt_module,
|
||||
"hermes_state": fake_hermes_state,
|
||||
}):
|
||||
streaming.STREAMS[fake_stream_id] = fake_queue
|
||||
streaming._run_agent_streaming(
|
||||
session_id="sess-clarify-timeout",
|
||||
msg_text="please run task",
|
||||
model="gpt-5.4",
|
||||
workspace="/tmp",
|
||||
stream_id=fake_stream_id,
|
||||
)
|
||||
|
||||
self.assertEqual(captured["clarify_result"], "selected")
|
||||
self.assertEqual(len(submit_payloads), 1)
|
||||
self.assertEqual(submit_payloads[0]["timeout_seconds"], 300)
|
||||
|
||||
|
||||
class TestSessionDBAST(unittest.TestCase):
|
||||
"""AST-level checks: verify the try/except is not inside _ENV_LOCK (deadlock guard)."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue