Merge pull request #1777 from nesquena/stage-309

v0.51.15 — 4-PR batch (#1762, #1767, #1769, #1770)
This commit is contained in:
nesquena-hermes
2026-05-06 19:06:58 -07:00
committed by GitHub
19 changed files with 1137 additions and 154 deletions
+28
View File
@@ -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:<key>:<model>: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
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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: <repo>/*
+11 -1
View File
@@ -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:
+40 -2
View File
@@ -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,
@@ -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
+158 -85
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+45
View File
@@ -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: '생성됨: ',
+3 -2
View File
@@ -1067,10 +1067,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const isQuotaExhausted=d.type==='quota_exhausted';
const isAuthMismatch=d.type==='auth_mismatch';
const isModelNotFound=d.type==='model_not_found';
const isNoResponse=d.type==='no_response';
const isNoResponse=d.type==='no_response'||d.type==='silent_failure';
const label=isQuotaExhausted?'Out of credits':isRateLimit?'Rate limit reached':isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):isModelNotFound?(typeof t==='function'?t('model_not_found_label'):'Model not found'):isNoResponse?'No response received':'Error';
const hint=d.hint?`\n\n*${d.hint}*`:'';
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`});
const details=d.details?String(d.details).replace(/```/g,'`\u200b``'):'';
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`,provider_details:details});
}catch(_){
S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'});
}
+35
View File
@@ -9,6 +9,7 @@ const ICONS={
dup:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="4.5" y="4.5" width="8.5" height="8.5" rx="1.5"/><path d="M3 11.5V3h8.5"/></svg>',
trash:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M4.5 4.5v8.5h7v-8.5"/><line x1="7" y1="7" x2="7" y2="11"/><line x1="9" y1="7" x2="9" y2="11"/></svg>',
more:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>',
edit:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M11.5 2.5l2 2L5 13H3v-2z"/><path d="M10 4l2 2"/></svg>',
};
// 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.)
+3
View File
@@ -761,6 +761,9 @@
.msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
.msg-body pre{background:var(--code-bg);border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;}
.msg-body pre code{background:none;padding:0;border-radius:0;color:var(--pre-text);font-size:13px;line-height:1.6;}
.provider-error-details{margin:12px 0 0;border:1px solid var(--border);border-radius:10px;background:var(--surface);overflow:hidden;}
.provider-error-details>summary{cursor: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 */
+45 -2
View File
@@ -4474,7 +4474,10 @@ function renderMessages(options){
return _renderAttachmentHtml(fname,fileUrl);
}).join('')}</div>`;
}
const bodyHtml = isUser ? _renderUserFencedBlocks(content) : renderMd(_stripXmlToolCallsDisplay(String(content)));
let bodyHtml = isUser ? _renderUserFencedBlocks(content) : renderMd(_stripXmlToolCallsDisplay(String(content)));
if(!isUser&&m.provider_details){
bodyHtml += `<details class="provider-error-details"><summary>Provider details</summary><pre><code>${esc(String(m.provider_details))}</code></pre></details>`;
}
const statusHtml = (!isUser&&m._statusCard) ? _statusCardHtml(m._statusCard) : '';
const isEditableUser=isUser&&rawIdx===lastUserRawIdx;
const editBtn = isEditableUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">${li('pencil',13)}</button>` : '';
@@ -6145,9 +6148,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;';
+8 -2
View File
@@ -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
+343
View File
@@ -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
+14 -22
View File
@@ -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"})
+224 -35
View File
@@ -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,87 @@ 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 _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"
_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 +143,116 @@ 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)
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.
@@ -86,7 +260,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 +279,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 +350,33 @@ 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)
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"
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)
+62
View File
@@ -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 '<details class="provider-error-details"' in ui_js
assert 'Provider details' in ui_js
assert '.provider-error-details' in style_css
@@ -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"