Merge remote-tracking branch 'origin/master' into stage-331

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
nesquena-hermes 2026-05-10 18:03:55 +00:00
commit 22991fa820
11 changed files with 978 additions and 475 deletions

View file

@ -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

View file

@ -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"},

View file

@ -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

View file

@ -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
View file

@ -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=$!

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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(_){}
});

View file

@ -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():

View file

@ -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

View file

@ -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)."""