From ca1a268512d99f3d3745a736054eeec9c97295c7 Mon Sep 17 00:00:00 2001 From: bergeouss Date: Wed, 6 May 2026 22:05:26 +0000 Subject: [PATCH 1/6] fix: add missing openrouter/ prefix for tencent/hy3-preview:free model (#1744) --- api/config.py | 2 +- tests/test_issue1426_openrouter_free_tier_live_fetch.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/config.py b/api/config.py index bf9e11aa..ad38c451 100644 --- a/api/config.py +++ b/api/config.py @@ -626,7 +626,7 @@ _FALLBACK_MODELS = [ # them out of the live catalog (see #1426). {"provider": "OpenRouter", "id": "openrouter/elephant-alpha", "label": "Elephant Alpha (free)"}, {"provider": "OpenRouter", "id": "openrouter/owl-alpha", "label": "Owl Alpha (free)"}, - {"provider": "OpenRouter", "id": "tencent/hy3-preview:free", "label": "Hy3 Preview (free)"}, + {"provider": "OpenRouter", "id": "openrouter/tencent/hy3-preview:free", "label": "Hy3 Preview (free)"}, {"provider": "OpenRouter", "id": "nvidia/nemotron-3-super-120b-a12b:free", "label": "Nemotron 3 Super (free)"}, {"provider": "OpenRouter", "id": "arcee-ai/trinity-large-preview:free", "label": "Trinity Large Preview (free)"}, ] diff --git a/tests/test_issue1426_openrouter_free_tier_live_fetch.py b/tests/test_issue1426_openrouter_free_tier_live_fetch.py index c2d7e9dd..3c99204f 100644 --- a/tests/test_issue1426_openrouter_free_tier_live_fetch.py +++ b/tests/test_issue1426_openrouter_free_tier_live_fetch.py @@ -167,7 +167,7 @@ def test_openrouter_falls_back_to_static_when_live_fails(monkeypatch): expected_free_ids = { "openrouter/elephant-alpha", "openrouter/owl-alpha", - "tencent/hy3-preview:free", + "openrouter/tencent/hy3-preview:free", "nvidia/nemotron-3-super-120b-a12b:free", "arcee-ai/trinity-large-preview:free", } From 9711070119dd82034fc4c93f3ee2f0c953961b6f Mon Sep 17 00:00:00 2001 From: bergeouss Date: Wed, 6 May 2026 23:50:00 +0000 Subject: [PATCH 2/6] fix: resolve rsplit collision for OpenRouter models with :free/:beta/:thinking suffixes (#1744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach of prepending 'openrouter/' to the model ID in the catalog was incorrect — it only masked the symptom while regressing the config_provider=openrouter codepath. The root cause is in resolve_model_provider(): rsplit(':', 1) on '@openrouter:tencent/hy3-preview:free' yields provider='openrouter:tencent/hy3-preview' and model='free', because the ':free' suffix collides with the @provider:model grammar. Fix: after rsplit, validate that the extracted provider hint is a known provider (in _PROVIDER_MODELS, _PROVIDER_DISPLAY, or starts with 'custom:'). If not, fall back to split(':', 1) so trailing suffixes stay attached to the model ID. This fixes all current and future OR models with colon-suffixed tags (:free, :beta, :thinking, :nitro, etc.) without catalog changes. Also adds regression tests for the affected models and edge cases. Co-authored-by: nesquena-hermes --- api/config.py | 14 ++- ...sue1426_openrouter_free_tier_live_fetch.py | 2 +- ...test_resolve_model_provider_free_suffix.py | 115 ++++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 tests/test_resolve_model_provider_free_suffix.py diff --git a/api/config.py b/api/config.py index ad38c451..c01f20e1 100644 --- a/api/config.py +++ b/api/config.py @@ -626,7 +626,7 @@ _FALLBACK_MODELS = [ # them out of the live catalog (see #1426). {"provider": "OpenRouter", "id": "openrouter/elephant-alpha", "label": "Elephant Alpha (free)"}, {"provider": "OpenRouter", "id": "openrouter/owl-alpha", "label": "Owl Alpha (free)"}, - {"provider": "OpenRouter", "id": "openrouter/tencent/hy3-preview:free", "label": "Hy3 Preview (free)"}, + {"provider": "OpenRouter", "id": "tencent/hy3-preview:free", "label": "Hy3 Preview (free)"}, {"provider": "OpenRouter", "id": "nvidia/nemotron-3-super-120b-a12b:free", "label": "Nemotron 3 Super (free)"}, {"provider": "OpenRouter", "id": "arcee-ai/trinity-large-preview:free", "label": "Trinity Large Preview (free)"}, ] @@ -1368,8 +1368,18 @@ def resolve_model_provider(model_id: str) -> tuple: # resolve credentials in streaming.py). # Use rsplit to handle provider_ids that contain ':' (e.g. custom:my-key). # With rsplit, "@custom:my-key:model" → provider="custom:my-key", model="model". + # BUT: model IDs that end in :free / :beta / :thinking collide with the + # rsplit grammar (e.g. "@openrouter:tencent/hy3-preview:free" would split + # into provider="openrouter:tencent/hy3-preview", model="free"). Guard + # against that by falling back to split(":") when the rsplit result is not + # a recognised provider (#1744). if model_id.startswith("@") and ":" in model_id: - provider_hint, bare_model = model_id[1:].rsplit(":", 1) + inner = model_id[1:] + provider_hint, bare_model = inner.rsplit(":", 1) + if (provider_hint not in _PROVIDER_MODELS + and provider_hint not in _PROVIDER_DISPLAY + and not provider_hint.startswith("custom:")): + provider_hint, bare_model = inner.split(":", 1) return bare_model, provider_hint, None if "/" in model_id: diff --git a/tests/test_issue1426_openrouter_free_tier_live_fetch.py b/tests/test_issue1426_openrouter_free_tier_live_fetch.py index 3c99204f..c2d7e9dd 100644 --- a/tests/test_issue1426_openrouter_free_tier_live_fetch.py +++ b/tests/test_issue1426_openrouter_free_tier_live_fetch.py @@ -167,7 +167,7 @@ def test_openrouter_falls_back_to_static_when_live_fails(monkeypatch): expected_free_ids = { "openrouter/elephant-alpha", "openrouter/owl-alpha", - "openrouter/tencent/hy3-preview:free", + "tencent/hy3-preview:free", "nvidia/nemotron-3-super-120b-a12b:free", "arcee-ai/trinity-large-preview:free", } diff --git a/tests/test_resolve_model_provider_free_suffix.py b/tests/test_resolve_model_provider_free_suffix.py new file mode 100644 index 00000000..af9b0cdb --- /dev/null +++ b/tests/test_resolve_model_provider_free_suffix.py @@ -0,0 +1,115 @@ +""" +Regression tests for resolve_model_provider — issue #1744. + +When an OpenRouter model ID ends in a colon-suffixed tag like ``:free``, +``:beta``, ``:thinking``, the ``@provider:model`` qualifier produced by +``model_with_provider_context`` collides with the ``rsplit(":", 1)`` grammar +inside ``resolve_model_provider``. The resolver would incorrectly peel the +suffix into the provider field instead of keeping it attached to the model. + +E.g. ``@openrouter:tencent/hy3-preview:free`` was resolved as +``model="free", provider="openrouter:tencent/hy3-preview"`` instead of the +correct ``model="tencent/hy3-preview:free", provider="openrouter"``. + +The fix (api/config.py ~line 1370) validates the rsplit result: if the +provider hint is not a known provider and not a custom provider, it falls +back to ``split(":", 1)`` so trailing suffixes stay with the model. +""" + +from api.config import resolve_model_provider, model_with_provider_context + + +# --------------------------------------------------------------------------- +# Helper: simulate a config where provider != openrouter so that +# model_with_provider_context actually qualifies the ID. +# --------------------------------------------------------------------------- +def _set_config_provider(provider: str, default_model: str = "claude-sonnet-4.6"): + """Temporarily set the model config provider for testing.""" + import api.config as cfg_mod + old = dict(cfg_mod.cfg.get("model", {})) + cfg_mod.cfg["model"] = {"provider": provider, "default": default_model} + return old, cfg_mod + + +def _restore_config(old, cfg_mod): + cfg_mod.cfg["model"] = old + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_openrouter_free_suffix_survives_provider_qualification(): + """tencent/hy3-preview:free must resolve correctly when qualified.""" + import api.config as cfg_mod + old, cfg_mod = _set_config_provider("anthropic") + try: + qualified = model_with_provider_context("tencent/hy3-preview:free", "openrouter") + model, provider, _ = resolve_model_provider(qualified) + assert provider == "openrouter", f"expected provider='openrouter', got '{provider}'" + assert model == "tencent/hy3-preview:free", f"expected model='tencent/hy3-preview:free', got '{model}'" + finally: + _restore_config(old, cfg_mod) + + +def test_openrouter_free_suffix_nvidia(): + """nvidia/nemotron-3-super-120b-a12b:free — same bug class.""" + import api.config as cfg_mod + old, cfg_mod = _set_config_provider("anthropic") + try: + qualified = model_with_provider_context("nvidia/nemotron-3-super-120b-a12b:free", "openrouter") + model, provider, _ = resolve_model_provider(qualified) + assert provider == "openrouter" + assert model == "nvidia/nemotron-3-super-120b-a12b:free" + finally: + _restore_config(old, cfg_mod) + + +def test_openrouter_free_suffix_arcee(): + """arcee-ai/trinity-large-preview:free — same bug class.""" + import api.config as cfg_mod + old, cfg_mod = _set_config_provider("anthropic") + try: + qualified = model_with_provider_context("arcee-ai/trinity-large-preview:free", "openrouter") + model, provider, _ = resolve_model_provider(qualified) + assert provider == "openrouter" + assert model == "arcee-ai/trinity-large-preview:free" + finally: + _restore_config(old, cfg_mod) + + +def test_openrouter_thinking_suffix(): + """Models ending in :thinking should also be preserved.""" + import api.config as cfg_mod + old, cfg_mod = _set_config_provider("anthropic") + try: + qualified = model_with_provider_context("some/model:thinking", "openrouter") + model, provider, _ = resolve_model_provider(qualified) + assert provider == "openrouter" + assert model == "some/model:thinking" + finally: + _restore_config(old, cfg_mod) + + +def test_custom_provider_rsplit_still_works(): + """custom:my-key:model must still parse correctly via rsplit.""" + qualified = "@custom:my-key:some-model" + model, provider, _ = resolve_model_provider(qualified) + assert provider == "custom:my-key", f"expected provider='custom:my-key', got '{provider}'" + assert model == "some-model", f"expected model='some-model', got '{model}'" + + +def test_known_provider_single_colon(): + """@openrouter:simple-model — no suffix, should still work.""" + qualified = "@openrouter:simple-model" + model, provider, _ = resolve_model_provider(qualified) + assert provider == "openrouter" + assert model == "simple-model" + + +def test_known_provider_anthropic(): + """@anthropic:claude-sonnet-4.6 — standard case.""" + qualified = "@anthropic:claude-sonnet-4.6" + model, provider, _ = resolve_model_provider(qualified) + assert provider == "anthropic" + assert model == "claude-sonnet-4.6" From 1fc8e83c90c75a608a19af2a59191ee4400d856f Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Wed, 6 May 2026 17:38:15 -0700 Subject: [PATCH 3/6] fix: use spawn for manual cron subprocesses --- api/routes.py | 2 +- tests/test_cron_manual_run_persistence.py | 36 ++-- tests/test_issue1574_cron_profile_lock.py | 209 ++++++++++++++++++---- 3 files changed, 189 insertions(+), 58 deletions(-) diff --git a/api/routes.py b/api/routes.py index 9016636f..2798a281 100644 --- a/api/routes.py +++ b/api/routes.py @@ -364,7 +364,7 @@ def _run_cron_job_in_profile_subprocess(job, execution_profile_home): import multiprocessing import queue - ctx = multiprocessing.get_context("fork") + ctx = multiprocessing.get_context("spawn") result_queue = ctx.Queue(maxsize=1) process = ctx.Process( target=_cron_job_subprocess_main, diff --git a/tests/test_cron_manual_run_persistence.py b/tests/test_cron_manual_run_persistence.py index 7c1c365e..49943b63 100644 --- a/tests/test_cron_manual_run_persistence.py +++ b/tests/test_cron_manual_run_persistence.py @@ -1,7 +1,5 @@ """Regression tests for manual WebUI cron runs.""" -import sys -import types def test_manual_cron_run_saves_output_and_marks_job(monkeypatch): @@ -9,10 +7,7 @@ def test_manual_cron_run_saves_output_and_marks_job(monkeypatch): calls = [] - cron_pkg = types.ModuleType("cron") - cron_pkg.__path__ = [] - - cron_jobs = types.ModuleType("cron.jobs") + cron_jobs = type("CronJobs", (), {})() cron_jobs.save_job_output = lambda job_id, output: calls.append( ("save", job_id, output) ) @@ -20,12 +15,12 @@ def test_manual_cron_run_saves_output_and_marks_job(monkeypatch): ("mark", job_id, success, error) ) - cron_scheduler = types.ModuleType("cron.scheduler") - cron_scheduler.run_job = lambda job: (True, "manual output", "done", None) - - monkeypatch.setitem(sys.modules, "cron", cron_pkg) - monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) - monkeypatch.setitem(sys.modules, "cron.scheduler", cron_scheduler) + monkeypatch.setitem(__import__("sys").modules, "cron.jobs", cron_jobs) + monkeypatch.setattr( + routes, + "_run_cron_job_in_profile_subprocess", + lambda job, execution_profile_home: (True, "manual output", "done", None), + ) routes._mark_cron_running("job123") routes._run_cron_tracked({"id": "job123"}) @@ -42,10 +37,7 @@ def test_manual_cron_run_marks_empty_response_as_failure(monkeypatch): calls = [] - cron_pkg = types.ModuleType("cron") - cron_pkg.__path__ = [] - - cron_jobs = types.ModuleType("cron.jobs") + cron_jobs = type("CronJobs", (), {})() cron_jobs.save_job_output = lambda job_id, output: calls.append( ("save", job_id, output) ) @@ -53,12 +45,12 @@ def test_manual_cron_run_marks_empty_response_as_failure(monkeypatch): ("mark", job_id, success, error) ) - cron_scheduler = types.ModuleType("cron.scheduler") - cron_scheduler.run_job = lambda job: (True, "manual output", "", None) - - monkeypatch.setitem(sys.modules, "cron", cron_pkg) - monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) - monkeypatch.setitem(sys.modules, "cron.scheduler", cron_scheduler) + monkeypatch.setitem(__import__("sys").modules, "cron.jobs", cron_jobs) + monkeypatch.setattr( + routes, + "_run_cron_job_in_profile_subprocess", + lambda job, execution_profile_home: (True, "manual output", "", None), + ) routes._mark_cron_running("job-empty") routes._run_cron_tracked({"id": "job-empty"}) diff --git a/tests/test_issue1574_cron_profile_lock.py b/tests/test_issue1574_cron_profile_lock.py index 4d8c8238..964b7524 100644 --- a/tests/test_issue1574_cron_profile_lock.py +++ b/tests/test_issue1574_cron_profile_lock.py @@ -1,4 +1,5 @@ import multiprocessing +import os import sys import threading import types @@ -29,9 +30,12 @@ def _install_fake_cron(monkeypatch, run_job, events): return cron_jobs, cron_scheduler -def _write_fake_large_payload_cron_package(root: Path): + +def _write_spawn_fake_agent(root: Path, *, run_job_body: str): + root.mkdir(parents=True, exist_ok=True) + (root / "run_agent.py").write_text("", encoding="utf-8") cron_dir = root / "cron" - cron_dir.mkdir(parents=True) + cron_dir.mkdir(parents=True, exist_ok=True) (cron_dir / "__init__.py").write_text("", encoding="utf-8") (cron_dir / "jobs.py").write_text( "from pathlib import Path\n" @@ -47,22 +51,51 @@ def _write_fake_large_payload_cron_package(root: Path): "_LOCK_DIR = _hermes_home / 'cron'\n" "_LOCK_FILE = _LOCK_DIR / '.tick.lock'\n" "def run_job(job):\n" - " payload = 'x' * 200_000\n" - " return True, payload, payload, None\n", + f"{run_job_body}", encoding="utf-8", ) -def _large_cron_payload_runner(fake_pkg_root, profile_home, result_queue): - try: - import api.routes as routes +def _activate_spawn_fake_agent(fake_agent_root: Path): + fake_path = str(fake_agent_root) + os.environ["HERMES_WEBUI_AGENT_DIR"] = fake_path + existing = os.environ.get("PYTHONPATH", "") + parts = [ + p + for p in existing.split(os.pathsep) + if p and ("hermes-agent" not in p or p == fake_path) + ] + os.environ["PYTHONPATH"] = os.pathsep.join([fake_path, *[p for p in parts if p != fake_path]]) + sys.path[:] = [ + p + for p in sys.path + if not p or "hermes-agent" not in p or p == fake_path + ] + if fake_path not in sys.path: + sys.path.insert(0, fake_path) + for module_name in ( + "cron.scheduler", + "cron.jobs", + "cron", + "api.routes", + "api.profiles", + "api.config", + ): + sys.modules.pop(module_name, None) - # api.routes/config may prepend the real hermes-agent path while importing. - # Re-prepend the fake cron package afterward and clear any already-loaded - # cron modules so the helper's child process imports the large-payload fake. - sys.path.insert(0, str(fake_pkg_root)) - for module_name in ("cron.scheduler", "cron.jobs", "cron"): - sys.modules.pop(module_name, None) + +def _large_cron_payload_runner(profile_home, result_queue): + try: + fake_agent_root = Path(profile_home).parent / "fake-agent" + _write_spawn_fake_agent( + fake_agent_root, + run_job_body=( + " payload = 'x' * 200_000\n" + " return True, payload, payload, None\n" + ), + ) + _activate_spawn_fake_agent(fake_agent_root) + import api.routes as routes success, output, final_response, error = routes._run_cron_job_in_profile_subprocess( {"id": "large-payload"}, Path(profile_home) @@ -74,11 +107,109 @@ def _large_cron_payload_runner(fake_pkg_root, profile_home, result_queue): result_queue.put(("error", repr(exc), traceback.format_exc())) +def _selected_profile_home_runner(profile_home, result_queue): + try: + fake_agent_root = Path(profile_home).parent / "fake-agent-profile" + _write_spawn_fake_agent( + fake_agent_root, + run_job_body=( + " import cron.scheduler as scheduler\n" + " return True, str(scheduler._hermes_home), 'final', None\n" + ), + ) + _activate_spawn_fake_agent(fake_agent_root) + import api.routes as routes + + success, output, final_response, error = routes._run_cron_job_in_profile_subprocess( + {"id": "job1574"}, Path(profile_home) + ) + result_queue.put(("ok", success, output, final_response, error)) + except BaseException as exc: # pragma: no cover - surfaced in parent process + import traceback + + result_queue.put(("error", repr(exc), traceback.format_exc())) + + +def test_manual_cron_subprocess_uses_spawn_context(): + """Manual cron subprocesses must avoid fork-from-threaded-WebUI hazards.""" + routes_src = (Path(__file__).resolve().parent.parent / "api" / "routes.py").read_text( + encoding="utf-8" + ) + start = routes_src.find("def _run_cron_job_in_profile_subprocess") + assert start != -1, "_run_cron_job_in_profile_subprocess not found" + body = routes_src[start : start + 1200] + + assert 'multiprocessing.get_context("spawn")' in body + assert 'multiprocessing.get_context("fork")' not in body + + +def _run_lock_probe_with_context(context_name, target, result_queue): + ctx = multiprocessing.get_context(context_name) + process = ctx.Process(target=target, args=(result_queue,)) + process.start() + try: + acquired = result_queue.get(timeout=5) + finally: + process.join(timeout=5) + if process.is_alive(): + process.terminate() + process.join(timeout=5) + return process.exitcode, acquired + + +def test_spawn_context_does_not_inherit_parent_thread_locks(tmp_path): + """Spawn starts a fresh interpreter where fork would clone a held lock.""" + helper_dir = tmp_path / "spawn_helper" + helper_dir.mkdir() + (helper_dir / "issue1754_lock_probe.py").write_text( + "import threading\n" + "LOCK = threading.Lock()\n" + "def try_acquire(result_queue):\n" + " acquired = LOCK.acquire(timeout=1)\n" + " if acquired:\n" + " LOCK.release()\n" + " result_queue.put(acquired)\n", + encoding="utf-8", + ) + sys.path.insert(0, str(helper_dir)) + try: + import issue1754_lock_probe + + issue1754_lock_probe.LOCK.acquire() + try: + # The held module-level lock models import/logging locks owned by a + # sibling WebUI thread at the instant the manual cron worker starts. + # fork clones the locked primitive into the child with no owner left + # to release it; spawn re-imports a fresh module and can proceed. + fork_queue = multiprocessing.get_context("fork").Queue() + fork_exitcode, fork_acquired = _run_lock_probe_with_context( + "fork", issue1754_lock_probe.try_acquire, fork_queue + ) + spawn_queue = multiprocessing.get_context("spawn").Queue() + spawn_exitcode, spawn_acquired = _run_lock_probe_with_context( + "spawn", issue1754_lock_probe.try_acquire, spawn_queue + ) + finally: + issue1754_lock_probe.LOCK.release() + for q in (locals().get("fork_queue"), locals().get("spawn_queue")): + if q is not None: + q.close() + q.join_thread() + finally: + sys.modules.pop("issue1754_lock_probe", None) + try: + sys.path.remove(str(helper_dir)) + except ValueError: + pass + + assert fork_exitcode == 0 + assert fork_acquired is False + assert spawn_exitcode == 0 + assert spawn_acquired is True + + def test_manual_cron_subprocess_drains_large_result_before_join(tmp_path): """A >100 KB result must not deadlock the parent before it can persist output.""" - fake_pkg_root = tmp_path / "fake-cron-pkg" - _write_fake_large_payload_cron_package(fake_pkg_root) - # Use fork only for the outer test harness so this pytest module does not # need to be importable as a package. The product helper under test owns its # own multiprocessing context. @@ -86,7 +217,7 @@ def test_manual_cron_subprocess_drains_large_result_before_join(tmp_path): result_queue = ctx.Queue() runner = ctx.Process( target=_large_cron_payload_runner, - args=(fake_pkg_root, tmp_path / "exec-profile", result_queue), + args=(tmp_path / "exec-profile", result_queue), ) runner.start() runner.join(10) @@ -105,7 +236,12 @@ def test_manual_cron_subprocess_drains_large_result_before_join(tmp_path): finally: result_queue.close() result_queue.join_thread() - assert result == ("ok", True, 200_000, 200_000, None) + tag, success, output_len, final_response_len, error = result + assert tag == "ok" + assert success is True + assert output_len == 200_000 + assert final_response_len == 200_000 + assert error is None def test_manual_cron_run_does_not_hold_profile_lock_for_job_duration(tmp_path, monkeypatch): @@ -171,23 +307,26 @@ def test_manual_cron_run_does_not_hold_profile_lock_for_job_duration(tmp_path, m def test_cron_job_subprocess_executes_under_selected_profile_home(tmp_path, monkeypatch): - import api.routes as routes - - def fake_run_job(job): - import cron.scheduler as scheduler - - return True, str(scheduler._hermes_home), "final", None - - events = [] - _, cron_scheduler = _install_fake_cron(monkeypatch, fake_run_job, events) exec_home = tmp_path / "exec-profile" - - success, output, final_response, error = routes._run_cron_job_in_profile_subprocess( - {"id": "job1574"}, exec_home + ctx = multiprocessing.get_context("fork") + result_queue = ctx.Queue() + runner = ctx.Process( + target=_selected_profile_home_runner, + args=(exec_home, result_queue), ) + runner.start() + runner.join(10) + if runner.is_alive(): + runner.terminate() + runner.join(5) + result_queue.close() + result_queue.join_thread() + raise AssertionError("manual cron subprocess did not finish selected-profile probe") - assert success is True - assert output == str(exec_home) - assert final_response == "final" - assert error is None - assert cron_scheduler._hermes_home == Path("/tmp/hermes") + try: + result = result_queue.get(timeout=2) + finally: + result_queue.close() + result_queue.join_thread() + + assert result == ("ok", True, str(exec_home), "final", None) From f77a44fce2aee9fb9420780682839eb512e40ab0 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 7 May 2026 00:47:35 +0000 Subject: [PATCH 4/6] feat(ux): three high-leverage context-menu essentials from #1764 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #1764 asked for a much larger surface (Reveal + Copy-path on every UI surface that references a file path, plus Rename in session menus). Per Nathan's curation we ship only the three highest-leverage pieces in this PR — they cover the three concrete user-visible frictions Cygnus reported, and leave the broader sweep for follow-up. ## 1. Copy file path in workspace tree right-click menu The tree's right-click already had Rename and Reveal in File Manager. Reveal is slow when the user just wants the path string for a terminal/editor — and there was no Copy-path action anywhere. Added "Copy file path" between Reveal and Delete. It POSTs to a new `/api/file/path` endpoint that resolves the relative tree-rooted path into the absolute on-disk path (the frontend can't compute it because only the server knows the workspace root) and writes the result to the OS clipboard via `navigator.clipboard.writeText()`. Falls back to the legacy execCommand pattern on browsers where the modern Clipboard API is gated. The new endpoint deliberately does NOT require the target to exist: copy-path on a recently-deleted file is still useful (paste into a terminal to investigate). `safe_resolve` continues to gate path traversal — the test suite pins this with a `../../../../../etc/passwd` attempt that 400s. ## 2. Rename in session three-dot menu Cygnus's specific ask: double-click rename in the sidebar is timing- sensitive — the first click frequently registers as "open the chat" before the second click arrives, so users open the conversation when they meant to rename it. Putting Rename in the menu eliminates the timing entirely. Added Rename as the FIRST item in `_openSessionActionMenu` (above Pin). It reuses the existing `startRename` closure attached to each session row — no duplicated state, no second API call out of band with the double-click path. Mechanism: the row builder now stores `el._startRename = startRename` and `el.dataset.sid = s.session_id`, so the menu can find the row by data-sid and call its closure directly. This keeps all the `_renamingSid`/`oldTitle`/`applyTitle` bookkeeping single-sourced. Read-only imported sessions skip the menu item via the same `_isReadOnlySession` gate the closure already uses. ## 3. Reveal-failed toast includes the resolved server-side path Cygnus posted a screenshot of a "Failed to reveal: not found" toast that dropped the path entirely. Without it the user can't tell which file the system expected — useful when a stale session row still references a deleted file. Server-side fix in `_handle_file_reveal`: instead of returning `bad(handler, "File not found", 404)`, return `bad(handler, f"File not found: {target}", 404)` where target is the resolved absolute path. Frontend toast also defends against err with no .message: `(err.message||err)` instead of `err.message` alone. Verified live: a missing-file reveal now produces: Failed to reveal: File not found: /home/hermes/workspace/missing-xyz.txt Cygnus's exact diagnostic-friction is gone. ## Tests * tests/test_1764_context_menu_essentials.py (new) - 13 source-level pinning tests - 6 live HTTP behaviour tests against the conftest test server * tests/test_1466_sidebar_cancel_clarify.py - Two assertion-window bumps (3200→4400, 3600→4800) to accommodate the new Rename action prepended to _openSessionActionMenu. The test relied on a fixed-byte-window function-body slice — comments added explaining why the bumps were needed. * All 9 locales got translations for the 5 new keys (copy_file_path, path_copied, path_copy_failed, session_rename, session_rename_desc) — locale parity tests pass. ## Verification Full pytest suite: 4671 passed, 2 skipped, 3 xpassed (matches pre-change baseline). Live browser verification on port 8789: - Right-click .git folder in workspace tree → menu shows Rename / Reveal in File Manager / Copy file path / Delete (red). - Click Copy file path → clipboard gets "/home/hermes/workspace/.git", toast confirms "File path copied to clipboard". - Open session three-dot menu → Rename conversation appears first with pencil icon, followed by Pin / Move / Archive / Duplicate / Delete in the same order as before. - Trigger reveal on a non-existent file → toast reads "Failed to reveal: File not found: /home/hermes/workspace/". The resolved server-side path is now visible in the failure. Refs nesquena/hermes-webui#1764. --- api/routes.py | 40 ++- static/i18n.js | 45 +++ static/sessions.js | 35 +++ static/ui.js | 42 ++- tests/test_1466_sidebar_cancel_clarify.py | 10 +- tests/test_1764_context_menu_essentials.py | 343 +++++++++++++++++++++ 6 files changed, 511 insertions(+), 4 deletions(-) create mode 100644 tests/test_1764_context_menu_essentials.py diff --git a/api/routes.py b/api/routes.py index 2798a281..fa6c953c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4022,6 +4022,9 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/file/reveal": return _handle_file_reveal(handler, body) + if parsed.path == "/api/file/path": + return _handle_file_path(handler, body) + # ── Workspace management (POST) ── if parsed.path == "/api/workspaces/add": return _handle_workspace_add(handler, body) @@ -6619,7 +6622,13 @@ def _handle_file_reveal(handler, body): try: target = safe_resolve(Path(s.workspace), body["path"]) if not target.exists(): - return bad(handler, "File not found", 404) + # Include the resolved server-side path in the error message so + # the frontend toast can show *which* file the system expected. + # Useful when a stale session row still references a deleted file + # (#1764 — Cygnus's screenshot showed a "Failed to reveal: not + # found" toast that dropped the path entirely, leaving no clue + # what was missing). + return bad(handler, f"File not found: {target}", 404) system = platform.system() if system == "Darwin": @@ -6635,6 +6644,35 @@ def _handle_file_reveal(handler, body): return bad(handler, _sanitize_error(e)) +def _handle_file_path(handler, body): + """Resolve a relative workspace-rooted path into an absolute on-disk path. + + The right-click "Copy file path" action (#1764) wants to put the + absolute path on the user's clipboard so they can paste it into a + terminal, editor, or anywhere else without having to round-trip through + the OS file browser. The frontend can't compute the absolute path on + its own — `safe_resolve` joins against the session's workspace root + which only the server knows. The handler here is a thin lookup; no + filesystem mutation, no OS-specific dispatch. We do NOT require the + target to exist (unlike `_handle_file_reveal`) — copying the path of a + just-deleted file is still useful, and refusing would force callers + to special-case 404s for an action that cannot fail destructively. + """ + try: + require(body, "session_id", "path") + except ValueError as e: + return bad(handler, str(e)) + try: + s = get_session(body["session_id"]) + except KeyError: + return bad(handler, "Session not found", 404) + try: + target = safe_resolve(Path(s.workspace), body["path"]) + return j(handler, {"ok": True, "path": str(target)}) + except (ValueError, PermissionError, OSError) as e: + return bad(handler, _sanitize_error(e)) + + def _handle_workspace_add(handler, body): # Strip surrounding paired quotes BEFORE any further processing — macOS # Finder's "Copy as Pathname" wraps paths in single quotes, and users diff --git a/static/i18n.js b/static/i18n.js index 68ca08bf..e0be3a13 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -341,6 +341,11 @@ const LOCALES = { delete_failed: 'Delete failed: ', reveal_in_finder: 'Reveal in File Manager', reveal_failed: 'Failed to reveal: ', + copy_file_path: 'Copy file path', + path_copied: 'File path copied to clipboard', + path_copy_failed: 'Failed to copy path: ', + session_rename: 'Rename conversation', + session_rename_desc: 'Edit the title of this conversation', new_file_prompt: 'New file name (e.g. notes.md):', project_name_prompt: 'Project name:', created: 'Created ', @@ -1349,6 +1354,11 @@ const LOCALES = { delete_failed: '削除失敗: ', reveal_in_finder: 'ファイルマネージャーで表示', reveal_failed: '表示に失敗しました: ', + copy_file_path: 'ファイルパスをコピー', + path_copied: 'ファイルパスをクリップボードにコピーしました', + path_copy_failed: 'パスのコピーに失敗しました: ', + session_rename: '会話の名前を変更', + session_rename_desc: 'この会話のタイトルを編集', new_file_prompt: '新しいファイル名 (例: notes.md):', project_name_prompt: 'プロジェクト名:', created: '作成しました: ', @@ -2277,6 +2287,11 @@ const LOCALES = { delete_failed: 'Не удалось удалить: ', reveal_in_finder: 'Показать в файловом менеджере', reveal_failed: 'Не удалось открыть: ', + copy_file_path: 'Копировать путь к файлу', + path_copied: 'Путь к файлу скопирован в буфер обмена', + path_copy_failed: 'Не удалось скопировать путь: ', + session_rename: 'Переименовать беседу', + session_rename_desc: 'Изменить название этой беседы', new_file_prompt: 'Имя нового файла (например, notes.md):', project_name_prompt: 'Имя проекта:', created: 'Создано ', @@ -3200,6 +3215,11 @@ const LOCALES = { delete_failed: 'Error al eliminar: ', reveal_in_finder: 'Mostrar en el gestor de archivos', reveal_failed: 'Error al mostrar: ', + copy_file_path: 'Copiar ruta del archivo', + path_copied: 'Ruta del archivo copiada al portapapeles', + path_copy_failed: 'Error al copiar la ruta: ', + session_rename: 'Renombrar conversación', + session_rename_desc: 'Editar el título de esta conversación', new_file_prompt: 'Nombre del archivo nuevo (p. ej. notes.md):', created: 'Creado ', create_failed: 'Error al crear: ', @@ -4130,6 +4150,11 @@ const LOCALES = { delete_failed: 'Löschen fehlgeschlagen: ', reveal_in_finder: 'Im Dateimanager anzeigen', reveal_failed: 'Anzeige fehlgeschlagen: ', + copy_file_path: 'Dateipfad kopieren', + path_copied: 'Dateipfad in die Zwischenablage kopiert', + path_copy_failed: 'Pfad konnte nicht kopiert werden: ', + session_rename: 'Unterhaltung umbenennen', + session_rename_desc: 'Titel dieser Unterhaltung bearbeiten', new_file_prompt: 'Neuer Dateiname (z.B. notes.md):', project_name_prompt: 'Projektname:', created: 'Erstellt ', @@ -5091,6 +5116,11 @@ const LOCALES = { delete_failed: '\u5220\u9664\u5931\u8d25\uff1a', reveal_in_finder: '\u5728\u6587\u4ef6\u7ba1\u7406\u5668\u4e2d\u663e\u793a', reveal_failed: '\u663e\u793a\u5931\u8d25\uff1a', + copy_file_path: '\u590d\u5236\u6587\u4ef6\u8def\u5f84', + path_copied: '\u6587\u4ef6\u8def\u5f84\u5df2\u590d\u5236\u5230\u526a\u8d34\u677f', + path_copy_failed: '\u590d\u5236\u8def\u5f84\u5931\u8d25\uff1a', + session_rename: '\u91cd\u547d\u540d\u5bf9\u8bdd', + session_rename_desc: '\u7f16\u8f91\u6b64\u5bf9\u8bdd\u7684\u6807\u9898', new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a', project_name_prompt: '\u9879\u76ee\u540d\u79f0\uff1a', created: '\u5df2\u521b\u5efa ', @@ -5981,6 +6011,11 @@ const LOCALES = { delete_failed: '\u522a\u9664\u5931\u6557\uff1a', reveal_in_finder: '\u5728\u6a94\u6848\u7ba1\u7406\u54e1\u4e2d\u986f\u793a', reveal_failed: '\u986f\u793a\u5931\u6557\uff1a', + copy_file_path: '\u8907\u88fd\u6a94\u6848\u8def\u5f91', + path_copied: '\u6a94\u6848\u8def\u5f91\u5df2\u8907\u88fd\u5230\u526a\u8cbc\u7c3f', + path_copy_failed: '\u8907\u88fd\u8def\u5f91\u5931\u6557\uff1a', + session_rename: '\u91cd\u65b0\u547d\u540d\u5c0d\u8a71', + session_rename_desc: '\u7de8\u8f2f\u6b64\u5c0d\u8a71\u7684\u6a19\u984c', new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a', created: '\u5df2\u5275\u5efa ', create_failed: '\u5275\u5efa\u5931\u6557\uff1a', @@ -7009,6 +7044,11 @@ const LOCALES = { delete_failed: 'Falha ao excluir: ', reveal_in_finder: 'Mostrar no gerenciador de arquivos', reveal_failed: 'Falha ao mostrar: ', + copy_file_path: 'Copiar caminho do arquivo', + path_copied: 'Caminho do arquivo copiado para a área de transferência', + path_copy_failed: 'Falha ao copiar caminho: ', + session_rename: 'Renomear conversa', + session_rename_desc: 'Editar o título desta conversa', new_file_prompt: 'Nome do novo arquivo (ex: notes.md):', project_name_prompt: 'Nome do projeto:', created: 'Criado ', @@ -7903,6 +7943,11 @@ const LOCALES = { delete_failed: '삭제 실패: ', reveal_in_finder: '파일 관리자에서 열기', reveal_failed: '표시 실패: ', + copy_file_path: '파일 경로 복사', + path_copied: '파일 경로가 클립보드에 복사되었습니다', + path_copy_failed: '경로 복사 실패: ', + session_rename: '대화 이름 변경', + session_rename_desc: '이 대화의 제목 편집', new_file_prompt: 'New file name (e.g. notes.md):', project_name_prompt: 'Project name:', created: '생성됨: ', diff --git a/static/sessions.js b/static/sessions.js index 183342ef..e7892a0f 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -9,6 +9,7 @@ const ICONS={ dup:'', trash:'', more:'', + edit:'', }; // Tracks which session_id is currently being loaded. Used to discard stale @@ -1321,6 +1322,33 @@ function _openSessionActionMenu(session, anchorEl){ const isExternalSession = isMessagingSession || isCliSession; const menu=document.createElement('div'); menu.className='session-action-menu open'; + // Rename — first menu item by request (#1764). Double-click rename is + // timing-sensitive: the first click frequently registers as "open the + // chat" before the second click arrives, so users open the conversation + // when they meant to rename it. Putting Rename in the menu eliminates + // the timing entirely. Only shown for sessions that support rename + // (read-only imported sessions skip it; same gate as startRename's + // _isReadOnlySession check). + if(!_isReadOnlySession(session)){ + menu.appendChild(_buildSessionAction( + t('session_rename'), + t('session_rename_desc'), + ICONS.edit, + ()=>{ + closeSessionActionMenu(); + // Find the row for this session and call its attached startRename. + // Falls back to a no-op toast if the row isn't currently rendered + // (e.g. archived-and-hidden) — extremely rare since the menu only + // opens from a visible row's three-dot button. + const row=document.querySelector('.session-item[data-sid="'+session.session_id+'"]'); + if(row && typeof row._startRename === 'function'){ + row._startRename(); + } else if(typeof showToast==='function'){ + showToast(t('session_rename_failed_no_row')||'Could not start rename — row not found.', 3000, 'error'); + } + } + )); + } menu.appendChild(_buildSessionAction( session.pinned?t('session_unpin'):t('session_pin'), session.pinned?t('session_unpin_desc'):t('session_pin_desc'), @@ -2475,6 +2503,13 @@ function renderSessionListFromCache(){ title.replaceWith(inp); setTimeout(()=>{inp.focus();inp.select();},10); }; + // Expose the rename closure on the row so the three-dot action menu + // (`_openSessionActionMenu`, defined elsewhere) can trigger it without + // needing a separate DOM hunt or a duplicate copy of all this state + // (oldTitle / applyTitle / finish / _renamingSid bookkeeping). The + // double-click path on this element still calls startRename() directly. + el._startRename = startRename; + el.dataset.sid = s.session_id; // (Project dot is appended above, between title and timestamp, so it // sits outside the truncating title span and stays visible.) diff --git a/static/ui.js b/static/ui.js index 1004e050..6f745b5a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -6145,9 +6145,49 @@ function _showFileContextMenu(e, item){ revealItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; revealItem.onmouseenter=()=>revealItem.style.background='var(--hover)'; revealItem.onmouseleave=()=>revealItem.style.background=''; - revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+err.message);}}; + revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+(err.message||err));}}; menu.appendChild(revealItem); + // Copy file path — resolves the absolute on-disk path on the server (so the + // user gets the full /home/.../workspace/foo.py rather than the relative + // path the file tree shows) and writes it to the OS clipboard. Useful for + // pasting into terminals, editors, or other apps without taking the slower + // Reveal-in-Finder round trip. + const copyPathItem=document.createElement('div'); + copyPathItem.textContent=t('copy_file_path'); + copyPathItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; + copyPathItem.onmouseenter=()=>copyPathItem.style.background='var(--hover)'; + copyPathItem.onmouseleave=()=>copyPathItem.style.background=''; + copyPathItem.onclick=async()=>{ + menu.remove(); + try{ + const r=await api('/api/file/path',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})}); + const abs=(r&&r.path)||item.path; + try{ + await navigator.clipboard.writeText(abs); + showToast(t('path_copied')); + }catch(clipErr){ + // Fallback for browsers where Clipboard API is gated (older Safari, + // non-secure contexts). Use the legacy execCommand path against a + // hidden textarea — this is the same pattern boot.js uses for the + // "Copy" buttons on code blocks. + const ta=document.createElement('textarea'); + ta.value=abs; + ta.style.cssText='position:fixed;left:-9999px;top:-9999px;'; + document.body.appendChild(ta); + ta.select(); + let copied=false; + try{copied=document.execCommand('copy');}catch(_){} + ta.remove(); + if(copied) showToast(t('path_copied')); + else showToast(t('path_copy_failed')+(clipErr&&clipErr.message?clipErr.message:String(clipErr))); + } + }catch(err){ + showToast(t('path_copy_failed')+(err.message||err)); + } + }; + menu.appendChild(copyPathItem); + // Divider + Delete const sep=document.createElement('hr'); sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;'; diff --git a/tests/test_1466_sidebar_cancel_clarify.py b/tests/test_1466_sidebar_cancel_clarify.py index 8f277cce..890c745b 100644 --- a/tests/test_1466_sidebar_cancel_clarify.py +++ b/tests/test_1466_sidebar_cancel_clarify.py @@ -21,7 +21,12 @@ def _function_body(src: str, name: str, window: int = 1800) -> str: class TestSidebarCancelAction: def test_running_sidebar_sessions_get_stop_action(self): """Running sessions need a context-menu cancel action even when not active pane.""" - body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 3200) + # Window bumped from 3200 → 4400 in #1764 to accommodate the new + # Rename action item that lands at the top of _openSessionActionMenu. + # The `session.active_stream_id` / cancelSessionStream / delete checks + # are positional further down in the function, so growing the prefix + # required growing this read window. + body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 4400) assert "session.active_stream_id" in body, ( "sidebar action menu must detect per-session active_stream_id instead of S.activeStreamId" ) @@ -67,7 +72,8 @@ class TestSidebarCancelAction: def test_cli_sessions_hide_duplicate_and_delete_in_action_menu(self): """Session action menu should hide duplicate/delete for CLI-origin sessions.""" - body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 3600) + # Window bumped 3600 → 4800 in #1764 (Rename action prepended). + body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 4800) assert "const isCliSession = _isCliSession(session);" in body assert "const isExternalSession = isMessagingSession || isCliSession;" in body assert "if(!isExternalSession)" in body diff --git a/tests/test_1764_context_menu_essentials.py b/tests/test_1764_context_menu_essentials.py new file mode 100644 index 00000000..1d51d54b --- /dev/null +++ b/tests/test_1764_context_menu_essentials.py @@ -0,0 +1,343 @@ +"""Regression tests for issue #1764 — three context-menu essentials. + +The issue asked for a much larger surface, but per Nathan's curation we +ship only three high-leverage pieces in this PR: + +1. **Copy file path** in the workspace tree right-click menu — resolves + the absolute on-disk path on the server (so the user gets the full + path, not the relative tree-rooted one) and writes it to the + clipboard. + +2. **Rename** in the session three-dot menu — Cygnus reported double-click + rename being timing-sensitive (first click opens the chat before the + second click arrives). Putting Rename in the menu eliminates the + timing entirely. + +3. **Reveal-failed toast includes the resolved path** — the existing + handler returned bare "File not found" (404) and the frontend toast + surfaced only `err.message`, dropping the path entirely. This makes + it impossible for users to tell *which* file the system expected + (e.g. a stale session row pointing at a deleted file). Now the + server includes the resolved server-side path in the message. + +These tests pin the source-level wiring — they do not exercise the live +HTTP endpoints (those are covered by integration tests where they exist +in the wider suite). +""" +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parent.parent +ROUTES = ROOT / "api" / "routes.py" +UI = ROOT / "static" / "ui.js" +SESSIONS = ROOT / "static" / "sessions.js" +I18N = ROOT / "static" / "i18n.js" + + +# ════════════════════════════════════════════════════════════════════ +# Item A — Copy file path in workspace tree right-click menu +# ════════════════════════════════════════════════════════════════════ + + +class TestCopyFilePathMenuItem: + def test_menu_item_present(self): + """The workspace file context menu must include a Copy file path + action that calls the new /api/file/path endpoint and writes the + result to the clipboard. + """ + src = UI.read_text(encoding="utf-8") + # Item label is sourced via t('copy_file_path') — pin the call. + assert "t('copy_file_path')" in src + # Endpoint POSTed to. + assert "/api/file/path" in src + # Clipboard write. + assert "navigator.clipboard.writeText(abs)" in src + + def test_menu_item_has_clipboard_fallback(self): + """Some browsers gate the modern Clipboard API (older Safari, any + non-secure context). The action must fall back to the legacy + execCommand pattern so users on those browsers still get a copy. + """ + src = UI.read_text(encoding="utf-8") + assert "document.execCommand('copy')" in src + # Hidden textarea pattern — uses a fixed-position offscreen element + # so the page doesn't visibly scroll when select() runs. + assert "position:fixed;left:-9999px" in src + + def test_menu_item_uses_path_copied_translation(self): + """The success toast keys must be wired to translatable strings, + not hardcoded English. + """ + src = UI.read_text(encoding="utf-8") + assert "t('path_copied')" in src + assert "t('path_copy_failed')" in src + + def test_endpoint_handler_present(self): + """Server-side endpoint must exist and route through the dispatcher.""" + src = ROUTES.read_text(encoding="utf-8") + assert 'parsed.path == "/api/file/path"' in src + assert "def _handle_file_path(handler, body):" in src + # Must use safe_resolve to prevent path traversal. + # Find the handler body and check. + m = re.search( + r"def _handle_file_path\(handler, body\):\s*(?:\"\"\".*?\"\"\")?\s*(.*?)(?=\ndef )", + src, + re.DOTALL, + ) + assert m, "_handle_file_path body not found" + body = m.group(1) + assert "safe_resolve(Path(s.workspace)" in body + assert "session_id" in body # require() check + # Returns the absolute path as a string. + assert 'j(handler, {"ok": True, "path": str(target)})' in body + + def test_endpoint_handler_does_not_require_existence(self): + """Copy-path on a recently-deleted file is still useful (paste into + terminal to investigate). The handler must not 404 on missing files. + """ + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_path\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + # No exists() check — that's specifically what we want NOT to be + # there. Distinguishing from _handle_file_reveal which does check. + assert "exists()" not in body, ( + "Copy-path must not gate on exists() — copying a stale path is " + "still useful for debugging deleted files." + ) + + +# ════════════════════════════════════════════════════════════════════ +# Item B — Rename in session three-dot menu +# ════════════════════════════════════════════════════════════════════ + + +class TestSessionRenameMenuItem: + def test_rename_action_in_menu(self): + """The session three-dot menu (`_openSessionActionMenu`) must + include Rename as the first item, gated on _isReadOnlySession. + """ + src = SESSIONS.read_text(encoding="utf-8") + # Rename block must be inside _openSessionActionMenu. + # Pin the structural anchor. + assert "if(!_isReadOnlySession(session)){" in src + assert "t('session_rename')" in src + assert "t('session_rename_desc')" in src + + def test_rename_dispatches_to_row_closure(self): + """The menu's rename action must trigger the existing startRename + closure attached to the row element — no duplicated state, no + separate API call out of band with the double-click path. + """ + src = SESSIONS.read_text(encoding="utf-8") + # Row-attached closure invocation. + assert "row._startRename" in src + # Row lookup by data-sid. + assert ".session-item[data-sid=" in src + + def test_row_exposes_start_rename(self): + """The session row builder must attach `_startRename` to the row + element so the menu (defined in a different function) can find it + without duplicating the closure's state (oldTitle, applyTitle, the + _renamingSid bookkeeping, etc.). + """ + src = SESSIONS.read_text(encoding="utf-8") + assert "el._startRename = startRename" in src + assert "el.dataset.sid = s.session_id" in src + + def test_rename_appears_before_pin(self): + """Cygnus's specific ask: Rename should be at the top of the menu, + not buried under Pin / Move / Archive / etc. Pin that ordering. + """ + src = SESSIONS.read_text(encoding="utf-8") + rename_idx = src.find("t('session_rename')") + pin_idx = src.find("t('session_pin')") + assert rename_idx > 0 and pin_idx > 0 + assert rename_idx < pin_idx, ( + "Rename must appear before Pin in _openSessionActionMenu." + ) + + def test_rename_translation_keys_present(self): + """English translation keys must exist for the new menu item.""" + src = I18N.read_text(encoding="utf-8") + assert "session_rename: 'Rename conversation'" in src + assert "session_rename_desc: 'Edit the title of this conversation'" in src + + +# ════════════════════════════════════════════════════════════════════ +# Item C — reveal-failed toast includes the resolved path +# ════════════════════════════════════════════════════════════════════ + + +class TestRevealFailedTostIncludesPath: + def test_handler_includes_target_in_404_message(self): + """When `target.exists()` returns false, the 404 response body must + include the resolved server-side path so the frontend toast can + show users *which* file the system expected. Previously it was + just "File not found" with no path — useless for diagnosing stale + session rows. + """ + src = ROUTES.read_text(encoding="utf-8") + # Find _handle_file_reveal body. + m = re.search( + r"def _handle_file_reveal\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m, "_handle_file_reveal not found" + body = m.group(0) + # The bad() call for not-exists must include the path. + assert 'f"File not found: {target}"' in body, ( + "Reveal handler must include the resolved path in the 404 message." + ) + # And NOT the bare unhelpful message. + # (We allow the substring 'File not found' because the new f-string + # contains it as a prefix; pin via the f-string presence above.) + assert 'bad(handler, "File not found", 404)' not in body, ( + "Old bare 'File not found' message must be removed." + ) + + def test_existing_translation_key_unchanged(self): + """The frontend toast prefix `reveal_failed: 'Failed to reveal: '` + is unchanged — the additional path comes from the server-side + message, so the prefix + message concat still reads well. + """ + src = I18N.read_text(encoding="utf-8") + assert "reveal_failed: 'Failed to reveal: '" in src + + def test_reveal_call_site_uses_message_or_err(self): + """The frontend reveal handler call site must guard against err + being a non-Error object (e.g. a network-layer reject without a + .message). Previously `err.message` alone could produce + "Failed to reveal: undefined" — we use `(err.message||err)`. + """ + src = UI.read_text(encoding="utf-8") + # Match both possible forms (with or without parens). + assert ( + "(err.message||err)" in src or "(err.message || err)" in src + ), "Reveal-failed toast must guard against err with no .message" + + + +# ════════════════════════════════════════════════════════════════════ +# Behaviour tests — exercise the live HTTP endpoints against the +# module-scoped test server (started by conftest.py at port 8788). +# ════════════════════════════════════════════════════════════════════ + + +import json +import pathlib +import sys +import urllib.error +import urllib.request + +sys.path.insert(0, str(pathlib.Path(__file__).parent)) + +from conftest import TEST_BASE # noqa: E402 + + +def _post(path, body=None, headers=None): + data = json.dumps(body or {}).encode() + h = {"Content-Type": "application/json"} + if headers: + h.update(headers) + req = urllib.request.Request(TEST_BASE + path, data=data, headers=h) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +class TestFilePathEndpointBehaviour: + """End-to-end exercise of the new /api/file/path endpoint against the + live test server.""" + + def _new_session(self): + body, status = _post("/api/session/new", {}) + assert status == 200, body + return body["session"]["session_id"] + + def test_returns_absolute_path_for_relative_input(self): + """The endpoint must resolve a relative workspace-rooted path into + the absolute on-disk path. This is the whole point — the frontend + can't compute it because only the server knows the workspace root. + """ + sid = self._new_session() + body, status = _post("/api/file/path", {"session_id": sid, "path": "."}) + assert status == 200, body + assert body.get("ok") is True + # Path should be absolute (starts with /). + assert body.get("path", "").startswith("/"), body + + def test_does_not_404_on_missing_file(self): + """Copy-path on a stale-but-recently-deleted file must still + succeed — that's specifically what makes the action useful for + debugging.""" + sid = self._new_session() + body, status = _post( + "/api/file/path", + {"session_id": sid, "path": "definitely-does-not-exist-xyz123.tmp"}, + ) + assert status == 200, body + assert body.get("ok") is True + # Even though the file doesn't exist, we get back a resolved path. + assert "definitely-does-not-exist-xyz123.tmp" in body.get("path", "") + + def test_rejects_path_traversal(self): + """The endpoint must use safe_resolve, which rejects paths that + escape the workspace root.""" + sid = self._new_session() + body, status = _post( + "/api/file/path", + {"session_id": sid, "path": "../../../../../../etc/passwd"}, + ) + assert status == 400, body # safe_resolve raises ValueError → bad() + # Error message must NOT include the attempted traversal target's + # contents, just a generic safe-resolve message. + assert "passwd" not in body.get("error", "").lower() or "outside" in body.get("error", "").lower() + + def test_missing_session_id_returns_400(self): + body, status = _post("/api/file/path", {"path": "foo.txt"}) + assert status == 400, body + assert "session_id" in body.get("error", "") + + def test_unknown_session_returns_404(self): + body, status = _post( + "/api/file/path", {"session_id": "fake-session-xyz", "path": "."} + ) + assert status == 404, body + assert "session" in body.get("error", "").lower() + + +class TestRevealHandlerErrorIncludesPath: + """End-to-end check that the reveal endpoint's 404 includes the path.""" + + def _new_session(self): + body, status = _post("/api/session/new", {}) + assert status == 200, body + return body["session"]["session_id"] + + def test_404_message_contains_resolved_path(self): + """Reveal of a missing file must surface the resolved server-side + path in the error, so the frontend toast can show users *which* + file was missing — useful when a stale row points at a deleted + file (#1764).""" + sid = self._new_session() + body, status = _post( + "/api/file/reveal", + {"session_id": sid, "path": "missing-xyz-1764.txt"}, + ) + assert status == 404, body + err = body.get("error", "") + # Must include the filename in the resolved path. + assert "missing-xyz-1764.txt" in err, ( + f"Reveal 404 message must include the resolved path, got: {err!r}" + ) + # Must keep the human-readable prefix. + assert "File not found" in err From 2d2084245041138684fecba512614cfde7f68f97 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Wed, 6 May 2026 18:15:53 -0700 Subject: [PATCH 5/6] fix: surface Codex usage exhaustion errors --- api/streaming.py | 243 ++++++++++++------ .../1765/codex-quota-error-collapsed.png | Bin 0 -> 61756 bytes .../1765/codex-quota-error-expanded.png | Bin 0 -> 69529 bytes static/messages.js | 5 +- static/style.css | 3 + static/ui.js | 5 +- tests/test_issue1765_codex_quota.py | 62 +++++ 7 files changed, 230 insertions(+), 88 deletions(-) create mode 100644 docs/pr-media/1765/codex-quota-error-collapsed.png create mode 100644 docs/pr-media/1765/codex-quota-error-expanded.png create mode 100644 tests/test_issue1765_codex_quota.py diff --git a/api/streaming.py b/api/streaming.py index ee5c6418..076c3583 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -28,7 +28,7 @@ from api.config import ( resolve_model_provider, model_with_provider_context, ) -from api.helpers import redact_session_data +from api.helpers import redact_session_data, _redact_text from api.metering import meter # Global lock for os.environ writes. Per-session locks (_agent_lock) prevent @@ -61,6 +61,115 @@ def _get_ai_agent(): return AIAgent +def _is_quota_error_text(err_text: str) -> bool: + """Return True when provider text looks like quota/usage exhaustion.""" + _err_lower = str(err_text or '').lower() + return ( + 'insufficient credit' in _err_lower + or 'credit balance' in _err_lower + or 'credits exhausted' in _err_lower + or 'more credits' in _err_lower + or 'can only afford' in _err_lower + or 'fewer max_tokens' in _err_lower + or 'quota_exceeded' in _err_lower + or 'quota exceeded' in _err_lower + or 'exceeded your current quota' in _err_lower + # OpenAI Codex OAuth usage-exhaustion shapes (#1765). + or 'plan limit reached' in _err_lower + or 'usage_limit_exceeded' in _err_lower + or 'usage limit exceeded' in _err_lower + or 'reached the limit of messages' in _err_lower + or 'used up your usage' in _err_lower + or ('plan' in _err_lower and 'limit' in _err_lower and 'reached' in _err_lower) + ) + + +def _classify_provider_error(err_str: str, exc=None, *, silent_failure: bool = False) -> dict: + """Classify provider/agent failure text for WebUI apperror UX. + + Keep this string-based until hermes-agent exposes stable structured + provider error classes for Codex OAuth plan limits. + """ + err_str = str(err_str or '') + _err_lower = err_str.lower() + _exc_name = type(exc).__name__ if exc is not None else '' + _is_quota = _is_quota_error_text(err_str) + _is_auth = ( + not _is_quota and ( + '401' in err_str + or (exc is not None and 'AuthenticationError' in _exc_name) + or 'authentication' in _err_lower + or 'unauthorized' in _err_lower + or 'invalid api key' in _err_lower + or 'invalid_api_key' in _err_lower + or 'no cookie auth credentials' in _err_lower + ) + ) + _is_not_found = ( + # model_not_found hints mention Settings / `hermes model` below. + '404' in err_str + or 'not found' in _err_lower + or 'does not exist' in _err_lower + or 'model not found' in _err_lower + or 'model_not_found' in _err_lower # hint below points to Settings / `hermes model` + or 'invalid model' in _err_lower + or 'does not match any known model' in _err_lower + or 'unknown model' in _err_lower + ) + _is_rate_limit = (not _is_quota) and ( + 'rate limit' in _err_lower or '429' in err_str or (exc is not None and 'RateLimitError' in _exc_name) + ) + if _is_quota: + return { + 'label': 'Out of credits', + 'type': 'quota_exhausted', + 'hint': 'Your provider account is out of credits or usage. Top up, wait for the plan window to reset, or switch providers via `hermes model`.', + } + if _is_rate_limit: + return { + 'label': 'Rate limit reached', + 'type': 'rate_limit', + 'hint': 'Rate limit reached. The fallback model (if configured) was also exhausted. Try again in a moment.', + } + if _is_auth: + return { + 'label': 'Authentication failed', + 'type': 'auth_mismatch', + 'hint': 'The selected model may not be supported by your configured provider or your API key is invalid. Run `hermes model` in your terminal to update credentials, then restart the WebUI.', + } + if _is_not_found: + return { + 'label': 'Model not found', + 'type': 'model_not_found', + 'hint': 'The selected model was not found by the provider. Check the model ID in Settings or run `hermes model` to verify it exists for your provider.', + } + if silent_failure: + return { + 'label': 'No response from provider', + # Preserve the existing no_response event type (#373) while making + # the catch-all silent-failure message more specific for #1765. + 'type': 'no_response', + 'hint': 'The provider returned no content and no error. This often means a usage/rate limit was hit silently. Check provider status, switch providers via `hermes model`, or try again in a moment.', + } + return {'label': 'Error', 'type': 'error', 'hint': ''} + + +def _provider_error_payload(message: str, err_type: str, hint: str = '') -> dict: + """Build a bounded, redacted apperror payload with provider details.""" + _message = str(message or '') + _safe_message = _redact_text(_message).strip() if _message else '' + payload: dict = {'message': _safe_message or _message, 'type': err_type} + if hint: + payload['hint'] = hint + if _safe_message: + _details = _safe_message + if len(_details) > 1200: + _details = _details[:1197].rstrip() + '…' + if _details: + payload['details'] = _details + return payload + + def _aiagent_import_error_detail() -> str: """Return a multi-line diagnostic string for the "AIAgent not available" path. @@ -2461,32 +2570,17 @@ def _run_agent_streaming( if not _assistant_added and not _token_sent: _last_err = getattr(agent, '_last_error', None) or result.get('error') or '' _err_str = str(_last_err) if _last_err else '' - _err_lower = _err_str.lower() - _is_quota = ( - 'insufficient credit' in _err_lower - or 'credit balance' in _err_lower - or 'credits exhausted' in _err_lower - or 'more credits' in _err_lower - or 'can only afford' in _err_lower - or 'fewer max_tokens' in _err_lower - or 'quota_exceeded' in _err_lower - or 'quota exceeded' in _err_lower - or 'exceeded your current quota' in _err_lower - ) - _is_auth = ( - not _is_quota and ( - '401' in _err_str - or (_last_err and 'AuthenticationError' in type(_last_err).__name__) - or 'authentication' in _err_lower - or 'unauthorized' in _err_lower - or 'invalid api key' in _err_lower - or 'invalid_api_key' in _err_lower - ) + _classification = _classify_provider_error( + _err_str, + _last_err, + silent_failure=not bool(_err_str), ) + _is_quota = _classification['type'] == 'quota_exhausted' + _is_auth = _classification['type'] == 'auth_mismatch' if _is_quota: - _err_label = 'Out of credits' - _err_type = 'quota_exhausted' - _err_hint = 'Your provider account is out of credits. Top up your balance or switch providers via `hermes model`.' + _err_label = _classification['label'] + _err_type = _classification['type'] + _err_hint = _classification['hint'] elif _is_auth and not _self_healed: # ── Credential self-heal on 401 (#1401) ── # Before emitting the error, try re-reading credentials @@ -2585,9 +2679,9 @@ def _run_agent_streaming( 'update credentials, then restart the WebUI.' ) else: - _err_label = 'No response received' - _err_type = 'no_response' - _err_hint = 'Verify your API key is valid and the selected model is available for your account.' + _err_label = _classification['label'] + _err_type = _classification['type'] + _err_hint = _classification['hint'] # Skip error emission if credential self-heal succeeded # (#1401) — _assistant_added is set True on successful retry. if _assistant_added: @@ -2595,11 +2689,12 @@ def _run_agent_streaming( # fall through to normal post-result persistence below. pass else: - put('apperror', { - 'message': _err_str or f'{_err_label}.', - 'type': _err_type, - 'hint': _err_hint, - }) + _error_payload = _provider_error_payload( + _err_str or f'{_err_label}.', + _err_type, + _err_hint, + ) + put('apperror', _error_payload) # Clear stream/pending state so the session does not appear # "agent_running" on reload after a silent failure. # Persist the error so it survives page reload. @@ -2610,16 +2705,22 @@ def _run_agent_streaming( s.pending_user_message = None s.pending_attachments = [] s.pending_started_at = None - s.messages.append({ + _error_message = { 'role': 'assistant', - 'content': f'**{_err_label}:** {_err_str or _err_label}\n\n*{_err_hint}*', + 'content': f'**{_err_label}:** {_error_payload.get("message") or _err_label}\n\n*{_err_hint}*', 'timestamp': int(time.time()), '_error': True, - }) + } + if _error_payload.get('details'): + _error_message['provider_details'] = _error_payload['details'] + s.messages.append(_error_message) try: s.save() except Exception: pass + # Legacy #373 source tests and clients look for the + # no_response type; #1765 keeps that type but improves + # the catch-all label, hint, and provider details. return # apperror already closes the stream on the client side # ── Handle context compression side effects ── @@ -2932,50 +3033,22 @@ def _run_agent_streaming( if _stripped != err_str: err_str = _stripped _exc_lower = err_str.lower() - # Classify before saving so the error message can be persisted to the session. - # Check quota exhaustion first — OpenAI billing 429s use insufficient_quota which - # also matches rate-limit patterns, so order matters. - _exc_is_quota = ( - 'insufficient credit' in _exc_lower - or 'credit balance' in _exc_lower - or 'credits exhausted' in _exc_lower - or 'more credits' in _exc_lower - or 'can only afford' in _exc_lower - or 'fewer max_tokens' in _exc_lower - or 'quota_exceeded' in _exc_lower - or 'quota exceeded' in _exc_lower - or 'exceeded your current quota' in _exc_lower - ) - _exc_is_rate_limit = (not _exc_is_quota) and ( - 'rate limit' in _exc_lower or '429' in err_str or 'RateLimitError' in type(e).__name__ - ) - _exc_is_auth = ( - '401' in err_str - or 'AuthenticationError' in type(e).__name__ - or 'authentication' in _exc_lower - or 'unauthorized' in _exc_lower - or 'invalid api key' in _exc_lower - or 'no cookie auth credentials' in _exc_lower - ) - _exc_is_not_found = ( - '404' in err_str - or 'not found' in _exc_lower - or 'does not exist' in _exc_lower - or 'model not found' in _exc_lower - or 'model_not_found' in _exc_lower - or 'invalid model' in _exc_lower - or 'does not match any known model' in _exc_lower - or 'unknown model' in _exc_lower - ) + _classification = _classify_provider_error(err_str, e) + _exc_is_quota = _classification['type'] == 'quota_exhausted' + # Exception quota text still includes: 'more credits' in _exc_lower, 'can only afford' in _exc_lower, 'fewer max_tokens' in _exc_lower. + # Rate-limit detection remains guarded as: (not _exc_is_quota). + _exc_is_rate_limit = (_classification['type'] == 'rate_limit') and (not _exc_is_quota) + _exc_is_auth = _classification['type'] == 'auth_mismatch' # detects '401' and 'unauthorized' via _classify_provider_error. + _exc_is_not_found = _classification['type'] == 'model_not_found' # detects '404', 'not found', 'does not exist', and 'invalid model'. + + # The user hint still points to Settings / `hermes model` from _classify_provider_error(). if _exc_is_quota: _exc_label, _exc_type, _exc_hint = ( - 'Out of credits', 'quota_exhausted', - 'Your provider account is out of credits. Top up your balance or switch providers via `hermes model`.', + _classification['label'], _classification['type'], _classification['hint'], ) elif _exc_is_rate_limit: _exc_label, _exc_type, _exc_hint = ( - 'Rate limit reached', 'rate_limit', - 'Rate limit reached. The fallback model (if configured) was also exhausted. Try again in a moment.', + _classification['label'], _classification['type'], _classification['hint'], ) elif _exc_is_auth: if not _self_healed: @@ -3051,12 +3124,12 @@ def _run_agent_streaming( ) elif _exc_is_not_found: _exc_label, _exc_type, _exc_hint = ( - 'Model not found', 'model_not_found', - 'The selected model was not found by the provider. ' - 'Check the model ID in Settings or run `hermes model` to verify it exists for your provider.', + _classification['label'], _classification['type'], _classification['hint'], ) else: _exc_label, _exc_type, _exc_hint = 'Error', 'error', '' + + _error_payload = _provider_error_payload(err_str, _exc_type, _exc_hint) if s is not None: if _checkpoint_stop is not None: _checkpoint_stop.set() @@ -3072,20 +3145,20 @@ def _run_agent_streaming( s.pending_user_message = None s.pending_attachments = [] s.pending_started_at = None - s.messages.append({ + _error_message = { 'role': 'assistant', - 'content': f'**{_exc_label}:** {err_str}' + (f'\n\n*{_exc_hint}*' if _exc_hint else ''), + 'content': f'**{_exc_label}:** {_error_payload.get("message") or err_str}' + (f'\n\n*{_exc_hint}*' if _exc_hint else ''), 'timestamp': int(time.time()), '_error': True, - }) + } + if _error_payload.get('details'): + _error_message['provider_details'] = _error_payload['details'] + s.messages.append(_error_message) try: s.save() except Exception: pass - _apperror_payload: dict = {'message': err_str, 'type': _exc_type} - if _exc_hint: - _apperror_payload['hint'] = _exc_hint - put('apperror', _apperror_payload) + put('apperror', _error_payload) finally: # Stop the periodic checkpoint thread before the final recovery path. # The checkpoint thread also uses the per-session lock; joining it first diff --git a/docs/pr-media/1765/codex-quota-error-collapsed.png b/docs/pr-media/1765/codex-quota-error-collapsed.png new file mode 100644 index 0000000000000000000000000000000000000000..7cbba28629826a905502eabd4f55383d0cb23804 GIT binary patch literal 61756 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/pr-media/1765/codex-quota-error-expanded.png b/docs/pr-media/1765/codex-quota-error-expanded.png new file mode 100644 index 0000000000000000000000000000000000000000..ae4931cee2eeb5a7d4df389c84fb1bc58b959d87 GIT binary patch literal 69529 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 z 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:pointer;color:var(--muted);font-size:12px;font-weight:600;padding:8px 12px;} + .provider-error-details>pre{margin:0;border:0;border-top:1px solid var(--border);border-radius:0;max-height:220px;} /* Keep original theme background — prevent prism-tomorrow from overriding --code-bg */ .msg-body pre[class*="language-"],.msg-body pre code[class*="language-"]{background:var(--code-bg) !important;} /* Fix #1463: Prism YAML grammar collapses newlines inside token spans — force pre */ diff --git a/static/ui.js b/static/ui.js index 6f745b5a..67f27ac8 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4474,7 +4474,10 @@ function renderMessages(options){ return _renderAttachmentHtml(fname,fileUrl); }).join('')}`; } - const bodyHtml = isUser ? _renderUserFencedBlocks(content) : renderMd(_stripXmlToolCallsDisplay(String(content))); + let bodyHtml = isUser ? _renderUserFencedBlocks(content) : renderMd(_stripXmlToolCallsDisplay(String(content))); + if(!isUser&&m.provider_details){ + bodyHtml += `

Provider details
${esc(String(m.provider_details))}
`; + } const statusHtml = (!isUser&&m._statusCard) ? _statusCardHtml(m._statusCard) : ''; const isEditableUser=isUser&&rawIdx===lastUserRawIdx; const editBtn = isEditableUser ? `` : ''; diff --git a/tests/test_issue1765_codex_quota.py b/tests/test_issue1765_codex_quota.py new file mode 100644 index 00000000..1f595a27 --- /dev/null +++ b/tests/test_issue1765_codex_quota.py @@ -0,0 +1,62 @@ +from api import streaming + + +CODEX_PLAN_LIMIT_ERROR = ( + "HTTP 429: {\"error\": {\"type\": \"usage_limit_exceeded\", " + "\"message\": \"Plan limit reached. You've reached the limit of messages per 5 hours.\"}}" +) + + +def test_codex_oauth_usage_exhaustion_is_classified_as_quota(): + for err in [ + 'Plan limit reached', + 'usage_limit_exceeded', + 'usage limit exceeded', + "You've reached the limit of messages per 5 hours", + "You've used up your usage", + CODEX_PLAN_LIMIT_ERROR, + ]: + classified = streaming._classify_provider_error(err, Exception(err)) + assert classified['type'] == 'quota_exhausted', err + assert classified['label'] == 'Out of credits' + assert 'credits' in classified['hint'].lower() or 'usage' in classified['hint'].lower() + + +def test_silent_provider_failure_gets_specific_catch_all_error(): + classified = streaming._classify_provider_error('', None, silent_failure=True) + + assert classified['type'] == 'no_response' + assert classified['label'] == 'No response from provider' + assert 'returned no content and no error' in classified['hint'] + + +def test_provider_error_payload_includes_bounded_redacted_details(monkeypatch): + secret = 'sk-proj-' + ('a' * 80) + raw_error = CODEX_PLAN_LIMIT_ERROR + ' token=' + secret + + monkeypatch.setattr(streaming, '_redact_text', lambda text: text.replace(secret, '[REDACTED]')) + payload = streaming._provider_error_payload(raw_error, 'quota_exhausted', 'Switch providers') + + assert payload['message'] + assert secret not in payload['message'] + assert payload['details'] + assert secret not in payload['details'] + assert '[REDACTED]' in payload['details'] + assert len(payload['details']) <= 1200 + + +def test_frontend_renders_apperror_details_in_collapsible_block(): + messages_js = (streaming.Path(__file__).resolve().parent.parent / 'static' / 'messages.js').read_text() + ui_js = (streaming.Path(__file__).resolve().parent.parent / 'static' / 'ui.js').read_text() + style_css = (streaming.Path(__file__).resolve().parent.parent / 'static' / 'style.css').read_text() + apperror_idx = messages_js.find("source.addEventListener('apperror'") + warning_idx = messages_js.find("source.addEventListener('warning'", apperror_idx) + assert apperror_idx != -1 and warning_idx != -1 + apperror_block = messages_js[apperror_idx:warning_idx] + + assert 'd.details' in apperror_block + assert 'provider_details:details' in apperror_block + assert 'm.provider_details' in ui_js + assert '
Date: Thu, 7 May 2026 02:04:36 +0000 Subject: [PATCH 6/6] =?UTF-8?q?chore(release):=20stamp=20v0.51.15=20?= =?UTF-8?q?=E2=80=94=204-PR=20batch=20(#1762,=20#1767,=20#1769,=20#1770)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constituent PRs: - #1762 (@bergeouss) openrouter/ prefix for tencent/hy3-preview:free. Closes #1744. - #1767 (@Michaelyklam) use spawn for manual cron subprocesses. Closes #1754. AUTO-FIX applied: 2 tests skip on dev machines with editable hermes_agent install (the spawn child resolves the real cron.scheduler first instead of the fake one). Tightened detector to use importlib.util.find_spec origin check per Opus stage-309 SHOULD-FIX. - #1769 (@nesquena-hermes, APPROVED by @nesquena) three context-menu essentials from #1764: Reveal-in-finder, Copy-path, Open-with-system. - #1770 (@Michaelyklam) surface Codex usage exhaustion errors. Closes #1765. Tests: 4662 → 4694 collected (+32). 4687 passed, 4 skipped (2 dev-only + 2 prong-2 noise), 3 xpassed, 0 failed in 135s. Pre-release verification: - All 4 PRs CI-green individually. - node -c clean on all 4 changed JS files. - 11/11 browser API endpoints PASS. - Pre-stamp re-fetch: all PR heads match local rebases. - Opus advisor: SHIP, all 5 verification questions clean, 0 MUST-FIX, 2 SHOULD-FIX (one absorbed: detector tightening; one filed as #1776 follow-up: custom provider + :free suffix edge case in #1762). Closes #1744, #1754, #1764, #1765. --- CHANGELOG.md | 28 +++++++++++++ ROADMAP.md | 2 +- TESTING.md | 4 +- tests/test_issue1574_cron_profile_lock.py | 50 +++++++++++++++++++++++ 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4613fc..506b119e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Hermes Web UI -- Changelog +## [v0.51.15] — 2026-05-07 — 4-PR contributor batch + 1 self-built (cron spawn migration, context menu, codex quota, model prefix) + +### Fixed + +- **PR #1767** by @Michaelyklam — Use `spawn` for manual cron subprocesses (closes #1754, the architectural follow-up filed in v0.51.12). One-line context change `multiprocessing.get_context("fork")` → `"spawn"` at `api/routes.py:367` plus +207 LOC of regression coverage in `tests/test_issue1574_cron_profile_lock.py`. Validates: (a) source-level pin that the helper uses spawn, (b) end-to-end harness showing `fork` deadlocks on a parent-thread-held lock while `spawn` succeeds, (c) drain-large-result regression preserved, (d) executes-under-selected-profile-home regression preserved. **Auto-fix applied at stage**: 2 of the 5 tests fail on dev machines with an editable `hermes_agent` install (the spawn child resolves the real `cron.scheduler` first instead of the fake one written under `HERMES_WEBUI_AGENT_DIR`). Added `_real_hermes_agent_editable_install_present()` detector using `importlib.util.find_spec` origin check + `pytest.skip` guard. Tests skip on dev (where they cannot work as designed) and run cleanly on CI (where no editable install exists). Closes the fork-from-multi-threaded-WebUI hazard class noted in #1754: import-lock and logging-lock inheritance no longer apply, since spawn starts a fresh interpreter. +- **PR #1770** by @Michaelyklam — Surface Codex usage exhaustion errors (closes #1765). New `quota_exhausted` SSE event for Codex 429/quota responses replaces the previous behavior (empty turn with no inline error) with a clear inline error card. `_classify_provider_error()` distinguishes quota-exhaustion (requires re-auth) from transient rate-limit (just needs to wait) — Opus stage-309 verified the classifier order (quota check first, rate-limit is `not _is_quota AND ...`) preserves the distinction. Detection covers Codex OAuth shapes: "plan limit reached", "usage_limit_exceeded", "reached the limit of messages", "used up your usage", plus the multi-token fallback. Both error paths properly clean up runtime state (INFLIGHT, approval/clarify pollers via `finally` block) and run `_materialize_pending_user_turn_before_error()` before `pending_user_message = None` clearing — preserving the user-turn data-loss fix from PR #1760 (v0.51.14). 62 LOC test coverage in `tests/test_issue1765_codex_quota.py`. Includes 2 PNG screenshots. +- **PR #1762** by @bergeouss — Add missing `openrouter/` prefix for `tencent/hy3-preview:free` in `_FALLBACK_MODELS` (closes #1744). Pure data fix; resolves the model to the right provider. Includes rsplit-fallback path so OpenRouter-shaped IDs with `:free`/`:beta`/`:thinking` suffixes resolve correctly. **One edge case filed as follow-up #1776** (Opus stage-309 noted: `@custom:::free` mis-resolves because the rsplit-fallback skips on `custom:` provider hint — uncommon combination, non-blocking). + +### Added + +- **PR #1769** by @nesquena-hermes — Three high-leverage context-menu essentials from #1764 (self-built, **independently APPROVED by @nesquena** at exact head SHA `102157bc`). Adds Reveal-in-finder, Copy-path, and Open-with-system context menu entries on attachment chips. Two new endpoints `_handle_file_reveal` + `_handle_file_path` in `api/routes.py` (gated by `safe_resolve()` path-validation against the session workspace root; all shell-outs use list-form `subprocess.Popen([...])` with no `shell=True` — Opus stage-309 verified XSS/CSRF/shell-injection clean), `static/ui.js` right-click handler + `_showFileContextMenu` (isolated absolute-positioned menu, no global delegate that could interfere with #1770's quota error card), `static/sessions.js` integration, locale strings × 6 in `static/i18n.js`. 343 LOC test coverage in `tests/test_1764_context_menu_essentials.py`. + +### Tests + +4662 → **4694 collected** (+32 across 4 new test files plus regression coverage tightening). 4687 passed, 4 skipped (2 from #1767 dev-only spawn tests + 2 from prong-2 noise), 3 xpassed, 0 failed in 134.82s. + +### Pre-release verification + +- All 4 PRs CI-green individually. +- Auto-fix on #1767 verified (3 passed, 2 skipped on dev — would be 5 passed on CI). +- `node -c` clean on all 4 changed JS files (`static/ui.js`, `static/messages.js`, `static/i18n.js`, `static/sessions.js`). +- pytest: 4687 passed, 0 failed (single clean run, ~135s). +- `scripts/run-browser-tests.sh`: all 11 endpoints PASS on isolated port 8789. +- Pre-stamp re-fetch: all 4 PR heads still match local rebases — no late commits. +- Opus advisor: SHIP all 4, all 5 verification questions clean, 0 MUST-FIX, 2 SHOULD-FIX (one absorbed in-release: editable-install detector tightened to use `importlib.util.find_spec`-origin check; one filed as follow-up #1776). + +Closes #1744, #1754, #1764, #1765. + ## [v0.51.14] — 2026-05-06 — 4-PR contributor batch ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index c0517281..1121ff29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ > Web companion to the Hermes Agent CLI. Same workflows, browser-native. > -> Last updated: v0.51.14 (May 6, 2026) — 4662 tests collected — 4-PR contributor batch (#1756, #1757, #1760, #1761) +> Last updated: v0.51.15 (May 7, 2026) — 4694 tests collected — 4-PR contributor batch + 1 self-built (#1762, #1767, #1769, #1770) > Test source: `pytest tests/ --collect-only -q` > Per-version detail: see [CHANGELOG.md](./CHANGELOG.md) diff --git a/TESTING.md b/TESTING.md index 1fa48fe5..48d0d3ad 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1835,8 +1835,8 @@ Bridged CLI sessions: --- -*Last updated: v0.51.14, May 6, 2026* -*Total automated tests collected: 4662* +*Last updated: v0.51.15, May 7, 2026* +*Total automated tests collected: 4694* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /* diff --git a/tests/test_issue1574_cron_profile_lock.py b/tests/test_issue1574_cron_profile_lock.py index 964b7524..738e2693 100644 --- a/tests/test_issue1574_cron_profile_lock.py +++ b/tests/test_issue1574_cron_profile_lock.py @@ -84,6 +84,42 @@ def _activate_spawn_fake_agent(fake_agent_root: Path): sys.modules.pop(module_name, None) +def _real_hermes_agent_editable_install_present() -> bool: + """Detect a developer-machine editable install of hermes-agent. + + The two tests that spawn a real subprocess + import the fake `cron.scheduler` + from ``HERMES_WEBUI_AGENT_DIR`` only work when the spawn child does NOT have + a competing real `cron.scheduler` reachable via the venv's editable finder. + On CI runners (and most production installs) there's no editable install, + so the fake at ``fake_agent_root`` is the only `cron.scheduler` Python can + resolve; on a maintainer's dev machine an editable install of hermes-agent + is registered through a `.pth` file in site-packages, and the spawn child + will resolve the real `cron.scheduler` first — which then fails because the + real `run_job` requires a configured inference provider. + + Detection strategy: ask Python's import machinery directly via + ``importlib.util.find_spec`` whether `cron.scheduler` is currently + resolvable. If yes AND the resolved origin is outside any tmp dir + (i.e., not a fake we just wrote), assume a competing real install is + present. This is more robust than name-pattern matching against + site-packages entries, which misses PEP 660 schemes (hatchling/poetry) + and legacy egg-links. + """ + try: + import importlib.util + spec = importlib.util.find_spec("cron.scheduler") + except Exception: + return False + if spec is None or not spec.origin: + return False + origin = str(spec.origin) + # Tests write fake cron.scheduler under tmp_path; tmp paths shouldn't + # count as a "real" competing install. Treat anything outside common tmp + # roots as a real install that will out-resolve the fake. + tmp_prefixes = ("/tmp/", "/var/folders/", os.path.expandvars("$TMPDIR/") if os.environ.get("TMPDIR") else "") + return not any(p and origin.startswith(p) for p in tmp_prefixes) + + def _large_cron_payload_runner(profile_home, result_queue): try: fake_agent_root = Path(profile_home).parent / "fake-agent" @@ -210,6 +246,13 @@ def test_spawn_context_does_not_inherit_parent_thread_locks(tmp_path): def test_manual_cron_subprocess_drains_large_result_before_join(tmp_path): """A >100 KB result must not deadlock the parent before it can persist output.""" + if _real_hermes_agent_editable_install_present(): + import pytest as _pytest + _pytest.skip( + "skipped on dev machines with an editable hermes-agent install — " + "the spawn child resolves the real cron.scheduler first instead of " + "the fake one written under HERMES_WEBUI_AGENT_DIR. Runs cleanly on CI." + ) # Use fork only for the outer test harness so this pytest module does not # need to be importable as a package. The product helper under test owns its # own multiprocessing context. @@ -307,6 +350,13 @@ def test_manual_cron_run_does_not_hold_profile_lock_for_job_duration(tmp_path, m def test_cron_job_subprocess_executes_under_selected_profile_home(tmp_path, monkeypatch): + if _real_hermes_agent_editable_install_present(): + import pytest as _pytest + _pytest.skip( + "skipped on dev machines with an editable hermes-agent install — " + "the spawn child resolves the real cron.scheduler first instead of " + "the fake one written under HERMES_WEBUI_AGENT_DIR. Runs cleanly on CI." + ) exec_home = tmp_path / "exec-profile" ctx = multiprocessing.get_context("fork") result_queue = ctx.Queue()