release: v0.50.238

Batch release — 12 PRs. Approved by @nesquena. Tests: 3061 passed.
This commit is contained in:
nesquena-hermes
2026-04-29 08:53:51 -07:00
committed by GitHub
25 changed files with 2173 additions and 88 deletions
+20
View File
@@ -2,6 +2,26 @@
## [Unreleased]
### Fixed
## [v0.50.238] — 2026-04-29
### Added
- **Portuguese (pt-BR) locale** — full i18n coverage for `pt` locale across all UI panels (chat, sessions, commands, settings, cron, workspace, profiles, skills). (`static/i18n.js`) @fecolinhares — Closes #1242
### Fixed
- **Compaction preserves visible prompts** — WebUI now keeps model-facing compacted context separately from the visible transcript, so automatic context compaction no longer replaces earlier user prompts in the scrollback. (`api/models.py`, `api/streaming.py`, `api/routes.py`) @franksong2702 — Closes #1217
- **MiniMax China provider visible in model picker**`MINIMAX_CN_API_KEY` now maps to the `minimax-cn` provider instead of being collapsed into global `minimax`; WebUI includes a static MiniMax (China) model catalog/display label so `providers.minimax-cn: {}` can render a populated picker group. (`api/config.py`, `api/providers.py`) @franksong2702 — Closes #1236
- **Terminal resize and collapse controls restored** — restores the collapse/expand dock markup and controlled height CSS variable lost during the v0.50.237 batch integration, and reinstates regression coverage for terminal resizing and collapsed-state behavior. (`static/index.html`, `static/style.css`, `static/terminal.js`, `tests/test_embedded_workspace_terminal.py`) @franksong2702
- **GET `/api/mcp/servers` returned 404** — the route was placed after `handle_get()`'s `return False` sentinel; moved inside the function before the 404 return. (`api/routes.py`) @KingBoyAndGirl — Closes #1251
- **MCP Servers UI showed Korean labels in English locale** — 26 i18n keys in the English locale block (`en`) were accidentally set to Korean translations from PR #538; replaced with correct English text. (`static/i18n.js`) @bergeouss — Closes #1254
- **Live model fetch for custom providers** — when `provider=custom`, the live-model endpoint now reads `model.base_url` from config and fetches `/v1/models` from the user's custom OpenAI-compat endpoint. (`api/routes.py`) @KingBoyAndGirl — Closes #1247
- **Profile terminal env applied in WebUI sessions**`api/terminal.py` now loads the active profile's env overlay before spawning the PTY shell. (`api/terminal.py`) @dso2ng — Closes #1245
- **SSRF: custom provider `base_url` trusted**`_is_ssrf_blocked()` now whitelists user-configured custom provider base URLs, preventing false SSRF blocks for legitimate private-network endpoints. (`api/routes.py`) @KingBoyAndGirl — Closes #1244
- **SESSION_AGENT_CACHE LRU limit** — unbounded dict replaced with `functools.lru_cache` (cap 256); prevents memory growth in long-running servers with many sessions. (`api/config.py`) @happy5318 — Closes #1250
- **Native image uploads as multimodal inputs** — image attachments uploaded to the workspace are now forwarded to vision-capable models as OpenAI-style `image_url` data-URL parts instead of text paths. Magic-byte validation rejects non-image files; workspace path validation uses `.resolve()` + `.relative_to()` (symlink-safe); 20 MiB per-image cap. (`api/streaming.py`, `api/routes.py`, `api/upload.py`, `static/ui.js`) @yzp12138 — Closes #1229
- **`@provider:model` hint preserved when hint matches active provider** — `_resolve_compatible_session_model()` was stripping the `@provider:` prefix when the hint matched the active provider, causing duplicate model IDs from different providers to snap back to the wrong provider on the next render. The hint is now returned unchanged so `resolve_model_provider()` can route correctly. (`api/routes.py`) @nesquena-hermes — Closes #1253
## [v0.50.237] — 2026-04-29
### Added
+23 -2
View File
@@ -536,6 +536,7 @@ _PROVIDER_DISPLAY = {
"kimi-coding": "Kimi / Moonshot",
"deepseek": "DeepSeek",
"minimax": "MiniMax",
"minimax-cn": "MiniMax (China)",
"google": "Google",
"meta-llama": "Meta Llama",
"huggingface": "HuggingFace",
@@ -581,6 +582,8 @@ _PROVIDER_ALIASES = {
"claude": "anthropic",
"claude-code": "anthropic",
"deep-seek": "deepseek",
"minimax-china": "minimax-cn",
"minimax_cn": "minimax-cn",
"opencode": "opencode-zen",
"grok": "xai",
"x-ai": "xai",
@@ -688,6 +691,12 @@ _PROVIDER_MODELS = {
{"id": "MiniMax-M2.5-highspeed", "label": "MiniMax M2.5 Highspeed"},
{"id": "MiniMax-M2.1", "label": "MiniMax M2.1"},
],
"minimax-cn": [
{"id": "MiniMax-M2.7", "label": "MiniMax M2.7"},
{"id": "MiniMax-M2.5", "label": "MiniMax M2.5"},
{"id": "MiniMax-M2.1", "label": "MiniMax M2.1"},
{"id": "MiniMax-M2", "label": "MiniMax M2"},
],
# GitHub Copilot — model IDs served via the Copilot API
"copilot": [
{"id": "gpt-5.5", "label": "GPT-5.5"},
@@ -1574,8 +1583,10 @@ def get_available_models() -> dict:
detected_providers.add("zai")
if all_env.get("KIMI_API_KEY"):
detected_providers.add("kimi-coding")
if all_env.get("MINIMAX_API_KEY") or all_env.get("MINIMAX_CN_API_KEY"):
if all_env.get("MINIMAX_API_KEY"):
detected_providers.add("minimax")
if all_env.get("MINIMAX_CN_API_KEY"):
detected_providers.add("minimax-cn")
if all_env.get("DEEPSEEK_API_KEY"):
detected_providers.add("deepseek")
if all_env.get("XAI_API_KEY"):
@@ -1660,6 +1671,11 @@ def get_available_models() -> dict:
# Build set of hostnames from custom_providers config — these are
# user-explicitly configured endpoints and should not be blocked by SSRF.
_ssrf_trusted_hosts: set[str] = set()
# Also trust the base_url from model config (explicitly configured by user)
if cfg_base_url:
_base_parsed = urlparse(cfg_base_url if "://" in cfg_base_url else f"http://{cfg_base_url}")
if _base_parsed.hostname:
_ssrf_trusted_hosts.add(_base_parsed.hostname.lower())
_custom_providers_cfg = cfg.get("custom_providers", [])
if isinstance(_custom_providers_cfg, list):
for _cp in _custom_providers_cfg:
@@ -1978,7 +1994,12 @@ SERVER_START_TIME = time.time()
# Agent cache: reuse AIAgent across messages in the same WebUI session so that
# _user_turn_count survives between turns. This mirrors the gateway's
# _agent_cache pattern and is required for injectionFrequency: "first-turn".
SESSION_AGENT_CACHE: dict = {} # session_id -> (AIAgent, config_sig)
# LRU cache with size limit to prevent memory bloat.
# All cache operations (get, set, move_to_end, popitem) are protected by
# SESSION_AGENT_CACHE_LOCK for thread safety in multi-threaded ASGI servers.
import collections
SESSION_AGENT_CACHE: collections.OrderedDict = collections.OrderedDict() # LRU cache
SESSION_AGENT_CACHE_MAX = 50 # Maximum cached agents (each holds full conversation history)
SESSION_AGENT_CACHE_LOCK = threading.Lock()
+2
View File
@@ -315,6 +315,7 @@ class Session:
pending_user_message: str=None,
pending_attachments=None,
pending_started_at=None,
context_messages=None,
compression_anchor_visible_idx=None,
compression_anchor_message_key=None,
**kwargs):
@@ -338,6 +339,7 @@ class Session:
self.pending_user_message = pending_user_message
self.pending_attachments = pending_attachments or []
self.pending_started_at = pending_started_at
self.context_messages = context_messages if isinstance(context_messages, list) else []
self.compression_anchor_visible_idx = compression_anchor_visible_idx
self.compression_anchor_message_key = compression_anchor_message_key
self._metadata_message_count = None
+83
View File
@@ -158,6 +158,89 @@ def get_hermes_home_for_profile(name: str) -> Path:
return _DEFAULT_HERMES_HOME
_TERMINAL_ENV_MAPPINGS = {
'backend': 'TERMINAL_ENV',
'env_type': 'TERMINAL_ENV',
'cwd': 'TERMINAL_CWD',
'timeout': 'TERMINAL_TIMEOUT',
'lifetime_seconds': 'TERMINAL_LIFETIME_SECONDS',
'modal_mode': 'TERMINAL_MODAL_MODE',
'docker_image': 'TERMINAL_DOCKER_IMAGE',
'docker_forward_env': 'TERMINAL_DOCKER_FORWARD_ENV',
'docker_env': 'TERMINAL_DOCKER_ENV',
'docker_mount_cwd_to_workspace': 'TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE',
'singularity_image': 'TERMINAL_SINGULARITY_IMAGE',
'modal_image': 'TERMINAL_MODAL_IMAGE',
'daytona_image': 'TERMINAL_DAYTONA_IMAGE',
'container_cpu': 'TERMINAL_CONTAINER_CPU',
'container_memory': 'TERMINAL_CONTAINER_MEMORY',
'container_disk': 'TERMINAL_CONTAINER_DISK',
'container_persistent': 'TERMINAL_CONTAINER_PERSISTENT',
'docker_volumes': 'TERMINAL_DOCKER_VOLUMES',
'persistent_shell': 'TERMINAL_PERSISTENT_SHELL',
'ssh_host': 'TERMINAL_SSH_HOST',
'ssh_user': 'TERMINAL_SSH_USER',
'ssh_port': 'TERMINAL_SSH_PORT',
'ssh_key': 'TERMINAL_SSH_KEY',
'ssh_persistent': 'TERMINAL_SSH_PERSISTENT',
'local_persistent': 'TERMINAL_LOCAL_PERSISTENT',
}
def _stringify_env_value(value) -> str:
if isinstance(value, bool):
return 'true' if value else 'false'
if isinstance(value, (list, dict)):
return json.dumps(value)
return str(value)
def get_profile_runtime_env(home: Path) -> dict[str, str]:
"""Return env vars needed to run an agent turn for a profile home.
WebUI profile switching is per-client/cookie scoped, so it intentionally
does not call ``switch_profile(..., process_wide=True)`` for every browser.
Agent/tool code still consumes terminal backend settings through
environment variables (matching ``hermes -p <profile>``), so streaming must
apply the selected profile's terminal config and ``.env`` for the duration
of that run.
"""
home = Path(home).expanduser()
env: dict[str, str] = {}
try:
import yaml as _yaml
cfg_path = home / 'config.yaml'
cfg = _yaml.safe_load(cfg_path.read_text(encoding='utf-8')) if cfg_path.exists() else {}
if not isinstance(cfg, dict):
cfg = {}
except Exception:
cfg = {}
terminal_cfg = cfg.get('terminal', {}) if isinstance(cfg, dict) else {}
if isinstance(terminal_cfg, dict):
for key, env_key in _TERMINAL_ENV_MAPPINGS.items():
if key in terminal_cfg and terminal_cfg[key] is not None:
env[env_key] = _stringify_env_value(terminal_cfg[key])
env_path = home / '.env'
if env_path.exists():
try:
for line in env_path.read_text(encoding='utf-8').splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
k = k.strip()
v = v.strip().strip('"').strip("'")
if k and v:
env[k] = v
except Exception:
logger.debug("Failed to read runtime env from %s", env_path)
return env
def _set_hermes_home(home: Path):
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
os.environ['HERMES_HOME'] = str(home)
+1
View File
@@ -39,6 +39,7 @@ _PROVIDER_ENV_VAR: dict[str, str] = {
"kimi-coding": "KIMI_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
"minimax": "MINIMAX_API_KEY",
"minimax-cn": "MINIMAX_CN_API_KEY",
"mistralai": "MISTRAL_API_KEY",
"x-ai": "XAI_API_KEY",
"opencode-zen": "OPENCODE_ZEN_API_KEY",
+104 -19
View File
@@ -328,7 +328,14 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]:
or (provider_normalized and provider_normalized == active_provider)
)
if hint_matches_active:
return bare_model, True
# The @provider:model hint explicitly names the active provider, so this
# selection is intentional — not a stale cross-provider artifact. Return
# the full @provider:model string unchanged so downstream (resolve_model_provider
# in config.py) can route through the correct provider. Stripping the prefix
# here would collapse duplicate model IDs from different providers back to the
# bare ID, causing the first matching provider to win on the next UI render
# and the wrong provider to be used for the agent run. (#1253)
return model, False
if _catalog_has_provider(
provider_raw,
@@ -1221,6 +1228,10 @@ def handle_get(handler, parsed) -> bool:
{"name": get_active_profile_name(), "path": str(get_active_hermes_home())},
)
# ── MCP Servers (GET) ──
if parsed.path == "/api/mcp/servers":
return _handle_mcp_servers_list(handler)
return False # 404
@@ -1632,19 +1643,6 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/workspaces/reorder":
return _handle_workspace_reorder(handler, body)
# ── MCP Servers ──
if parsed.path == "/api/mcp/servers":
return _handle_mcp_servers_list(handler)
if parsed.path.startswith("/api/mcp/servers/") and parsed.path.count("/") == 4:
# DELETE /api/mcp/servers/<name>
name = parsed.path.split("/")[-1]
if handler.command == "DELETE":
return _handle_mcp_server_delete(handler, name)
# PUT /api/mcp/servers/<name>
if handler.command == "PUT":
return _handle_mcp_server_update(handler, name, body)
# ── Approval (POST) ──
if parsed.path == "/api/approval/respond":
return _handle_approval_respond(handler, body)
@@ -2735,6 +2733,48 @@ def _handle_live_models(handler, parsed):
]
except Exception:
pass
# If still no ids, try fetching from model.base_url directly (OpenAI-compat endpoint)
if not ids and provider == "custom":
_base_url = cfg.get("model", {}).get("base_url")
_api_key = cfg.get("model", {}).get("api_key")
if _base_url and _api_key:
try:
import urllib.request
import json
# Build the models endpoint URL
# AxonHub and similar OpenAI-compat endpoints serve /v1/models
_ep = _base_url.rstrip("/")
# If base_url already ends with /v1, use /models; otherwise add /v1/models
if _ep.endswith("/v1"):
_models_url = f"{_ep}/models"
else:
_models_url = f"{_ep}/v1/models"
_req = urllib.request.Request(
_models_url,
headers={"Authorization": f"Bearer {_api_key}"},
)
with urllib.request.urlopen(_req, timeout=8) as _resp:
_body = json.loads(_resp.read())
# Parse response: {"data": [{"id": "model1", ...}, ...]}
if isinstance(_body, dict):
_data = _body.get("data", [])
if isinstance(_data, list):
ids = [m.get("id", "") for m in _data if m.get("id")]
elif isinstance(_body, list):
ids = [m.get("id", m) if isinstance(m, dict) else m for m in _body]
if ids:
logger.debug("Live-fetched %d models from custom provider %s", len(ids), _base_url)
else:
logger.debug("Custom provider returned no models from %s", _base_url)
except Exception as _fetch_err:
logger.debug("Live fetch from custom provider failed: %s", _fetch_err)
# ── OpenAI-compat live fetch fallback ──────────────────────────────────
# When provider_model_ids() is unavailable or returns [] for a provider
@@ -3087,7 +3127,7 @@ def _handle_chat_start(handler, body):
msg = str(body.get("message", "")).strip()
if not msg:
return bad(handler, "message is required")
attachments = [str(a) for a in (body.get("attachments") or [])][:20]
attachments = _normalize_chat_attachments(body.get("attachments") or [])[:20]
try:
workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace))
except ValueError as e:
@@ -3137,6 +3177,36 @@ def _handle_chat_start(handler, body):
return j(handler, response)
def _normalize_chat_attachments(raw_attachments):
"""Normalize attachment payloads from the browser.
Older clients send a list of filenames. Newer clients send upload result
objects containing name/path/mime/size so image attachments can be supplied
to Hermes as native multimodal inputs for the current turn.
"""
normalized = []
if not isinstance(raw_attachments, list):
return normalized
for item in raw_attachments:
if isinstance(item, dict):
name = str(item.get("name") or item.get("filename") or "").strip()
path = str(item.get("path") or "").strip()
mime = str(item.get("mime") or "").strip()
att = {"name": name or path, "path": path, "mime": mime}
size = item.get("size")
if isinstance(size, int):
att["size"] = size
is_image = item.get("is_image")
if isinstance(is_image, bool):
att["is_image"] = is_image
normalized.append(att)
else:
value = str(item).strip()
if value:
normalized.append({"name": value, "path": "", "mime": ""})
return normalized
def _handle_chat_sync(handler, body):
"""Fallback synchronous chat endpoint (POST /api/chat). Not used by frontend."""
s = get_session(body["session_id"])
@@ -3207,14 +3277,20 @@ def _handle_chat_sync(handler, body):
"write_file, read_file, search_files, terminal workdir, and patch. "
"Never fall back to a hardcoded path when this tag is present."
)
from api.streaming import _sanitize_messages_for_api, _restore_reasoning_metadata
from api.streaming import (
_merge_display_messages_after_agent_result,
_restore_reasoning_metadata,
_sanitize_messages_for_api,
_session_context_messages,
)
_previous_messages = list(s.messages or [])
_previous_context_messages = list(_session_context_messages(s))
result = agent.run_conversation(
user_message=workspace_ctx + msg,
system_message=workspace_system_msg,
conversation_history=_sanitize_messages_for_api(s.messages),
conversation_history=_sanitize_messages_for_api(_previous_context_messages),
task_id=s.session_id,
persist_user_message=msg,
)
@@ -3233,9 +3309,17 @@ def _handle_chat_sync(handler, body):
else:
os.environ["HERMES_SESSION_KEY"] = old_session_key
with _get_session_agent_lock(s.session_id):
s.messages = _restore_reasoning_metadata(
_result_messages = result.get("messages") or _previous_context_messages
_next_context_messages = _restore_reasoning_metadata(
_previous_context_messages,
_result_messages,
)
s.context_messages = _next_context_messages
s.messages = _merge_display_messages_after_agent_result(
_previous_messages,
result.get("messages") or s.messages,
_previous_context_messages,
_restore_reasoning_metadata(_previous_messages, _result_messages),
msg,
)
# Only auto-generate title when still default; preserves user renames
if s.title == "Untitled":
@@ -3861,6 +3945,7 @@ def _handle_session_compress(handler, body):
return bad(handler, "Session was modified during compression; please retry.", 409)
s.messages = compressed
s.context_messages = compressed
s.tool_calls = []
s.active_stream_id = None
s.pending_user_message = None
+20
View File
@@ -15,6 +15,18 @@ from api.models import get_session, SESSIONS
logger = logging.getLogger(__name__)
def _truncate_at_last_user(messages):
history = messages or []
last_user_idx = None
for i in range(len(history) - 1, -1, -1):
if isinstance(history[i], dict) and history[i].get('role') == 'user':
last_user_idx = i
break
if last_user_idx is None:
return None
return history[:last_user_idx]
def retry_last(session_id: str) -> dict[str, Any]:
"""Truncate the session to before the last user message, return its text.
@@ -63,6 +75,10 @@ def retry_last(session_id: str) -> dict[str, Any]:
last_user_text = _extract_text(history[last_user_idx].get('content', ''))
removed_count = len(history) - last_user_idx
s.messages = history[:last_user_idx]
if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages:
truncated_context = _truncate_at_last_user(s.context_messages)
if truncated_context is not None:
s.context_messages = truncated_context
s.save()
return {'last_user_text': last_user_text, 'removed_count': removed_count}
@@ -98,6 +114,10 @@ def undo_last(session_id: str) -> dict[str, Any]:
removed_text = _extract_text(history[last_user_idx].get('content', ''))
removed_count = len(history) - last_user_idx
s.messages = history[:last_user_idx]
if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages:
truncated_context = _truncate_at_last_user(s.context_messages)
if truncated_context is not None:
s.context_messages = truncated_context
s.save() # outside LOCK -- save() re-acquires LOCK via _write_session_index()
preview = (removed_text[:40] + '...') if len(removed_text) > 40 else removed_text
return {
+218 -7
View File
@@ -2,9 +2,11 @@
Hermes Web UI -- SSE streaming engine and agent thread runner.
Includes Sprint 10 cancel support via CANCEL_FLAGS.
"""
import base64
import contextlib
import json
import logging
import mimetypes
import os
import queue
import re
@@ -63,6 +65,93 @@ from api.workspace import set_last_workspace
# metadata added by the webui and must be stripped before the API call.
_API_SAFE_MSG_KEYS = {'role', 'content', 'tool_calls', 'tool_call_id', 'name', 'refusal'}
_NATIVE_IMAGE_MAX_BYTES = 20 * 1024 * 1024
def _attachment_name(att) -> str:
if isinstance(att, dict):
return str(att.get('name') or att.get('filename') or att.get('path') or '').strip()
return str(att or '').strip()
_IMAGE_MAGIC: dict[bytes | None, frozenset[str]] = {
b'\x89PNG\r\n\x1a\n': frozenset({'image/png'}),
b'\xff\xd8\xff': frozenset({'image/jpeg'}),
b'GIF87a': frozenset({'image/gif'}),
b'GIF89a': frozenset({'image/gif'}),
b'RIFF': frozenset({'image/webp'}),
b'BM': frozenset({'image/bmp'}),
None: frozenset({'image/svg+xml'}),
}
def _is_valid_image(path: Path, mime: str) -> bool:
"""Check that the file's first bytes match the expected image MIME type.
Uses simple magic-number detection (no external dependency). SVG is
allowed through because it is text-based and has no binary signature.
"""
if not mime.startswith('image/'):
return False
mime_base = mime.split(';', 1)[0]
if mime_base == 'image/svg+xml':
return True
try:
with path.open('rb') as fh:
head = fh.read(16)
except OSError:
return False
for magic, mimes in _IMAGE_MAGIC.items():
if magic is not None and head.startswith(magic) and mime_base in mimes:
return True
return False
def _build_native_multimodal_message(workspace_ctx: str, msg_text: str, attachments, workspace: str):
"""Build native multimodal content parts for current-turn image uploads.
WebUI uploads files into the active workspace. For image files, pass the
bytes to Hermes as OpenAI-style image_url data URLs so vision-capable main
models can consume them in the same request. Non-image files intentionally
stay as text path attachments so the agent can inspect them with file tools.
"""
if not attachments:
return workspace_ctx + msg_text
parts = [{'type': 'text', 'text': workspace_ctx + msg_text}]
workspace_root = Path(workspace).expanduser().resolve()
image_count = 0
for att in attachments or []:
if not isinstance(att, dict):
continue
raw_path = str(att.get('path') or '').strip()
if not raw_path:
continue
try:
path = Path(raw_path).expanduser().resolve()
# Uploads should live inside the selected workspace. Do not read
# arbitrary paths from client-provided attachment metadata.
path.relative_to(workspace_root)
if not path.is_file():
continue
size = path.stat().st_size
if size <= 0 or size > _NATIVE_IMAGE_MAX_BYTES:
continue
mime = str(att.get('mime') or '').strip() or (mimetypes.guess_type(path.name)[0] or '')
if not mime.startswith('image/') or not _is_valid_image(path, mime):
continue
data = base64.b64encode(path.read_bytes()).decode('ascii')
except Exception:
continue
parts.append({
'type': 'image_url',
'image_url': {'url': f'data:{mime};base64,{data}'},
})
image_count += 1
return parts if image_count else workspace_ctx + msg_text
def _strip_thinking_markup(text: str) -> str:
"""Remove common reasoning/thinking wrappers from model text."""
@@ -998,6 +1087,101 @@ def _restore_reasoning_metadata(previous_messages, updated_messages):
return updated_messages
def _session_context_messages(session):
"""Return model-facing history without assuming it matches the UI transcript."""
context_messages = getattr(session, 'context_messages', None)
if isinstance(context_messages, list) and context_messages:
return context_messages
return session.messages or []
def _message_identity(msg):
if not isinstance(msg, dict):
return None
role = str(msg.get('role') or '')
content = msg.get('content', '')
text = _message_text(content)
if not text and not msg.get('tool_call_id') and not msg.get('tool_calls'):
return None
return (
role,
" ".join(str(text or '').split())[:500],
str(msg.get('tool_call_id') or ''),
json.dumps(msg.get('tool_calls') or [], sort_keys=True, ensure_ascii=False),
)
def _messages_have_prefix(messages, prefix):
if len(messages or []) < len(prefix or []):
return False
for idx, expected in enumerate(prefix or []):
if _message_identity((messages or [])[idx]) != _message_identity(expected):
return False
return True
def _is_context_compression_marker(msg):
if not isinstance(msg, dict):
return False
text = _message_text(msg.get('content', '')).lower()
return (
'context compaction' in text
or 'context compression' in text
or 'context was auto-compressed' in text
or 'active task list was preserved across context compression' in text
)
def _find_current_user_turn(messages, msg_text):
needle = " ".join(str(msg_text or '').split())
fallback = None
for idx, msg in enumerate(messages or []):
if not isinstance(msg, dict) or msg.get('role') != 'user':
continue
fallback = idx
text = " ".join(_message_text(msg.get('content', '')).split())
if needle and (needle in text or text in needle):
return idx
return fallback
def _merge_display_messages_after_agent_result(previous_display, previous_context, result_messages, msg_text):
"""Keep UI transcript durable while allowing model context to compact.
If Hermes Agent returns a normal append-only history, append that delta to
the UI transcript. If the model/context history was compacted and no longer
has the prior context as a prefix, keep the previous UI transcript and append
only compaction marker messages plus the current user turn onward.
"""
previous_display = list(previous_display or [])
previous_context = list(previous_context or [])
result_messages = list(result_messages or [])
if not result_messages:
return previous_display
if _messages_have_prefix(result_messages, previous_context):
candidates = result_messages[len(previous_context):]
else:
current_user_idx = _find_current_user_turn(result_messages, msg_text)
marker_candidates = [
m for m in result_messages[:current_user_idx if current_user_idx is not None else len(result_messages)]
if _is_context_compression_marker(m)
]
turn_candidates = result_messages[current_user_idx:] if current_user_idx is not None else []
candidates = marker_candidates + turn_candidates
merged = previous_display[:]
seen = {_message_identity(m) for m in merged}
for msg in candidates:
key = _message_identity(msg)
if _is_context_compression_marker(msg) and key is not None and key in seen:
continue
merged.append(copy.deepcopy(msg))
if key is not None:
seen.add(key)
return merged
def _tool_result_snippet(raw) -> str:
"""Extract a compact result preview from a stored tool message payload."""
text = str(raw or '')
@@ -1155,6 +1339,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
old_exec_ask = None
old_session_key = None
old_hermes_home = None
old_profile_env = {}
# ── MCP Server Discovery (lazy import, idempotent) ──
# discover_mcp_tools() is called here (rather than at server startup) so that
@@ -1226,12 +1411,16 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
# two concurrent tabs on different profiles don't clobber each other via the
# process-level active-profile global. Falls back gracefully.
try:
from api.profiles import get_hermes_home_for_profile
_profile_home = str(get_hermes_home_for_profile(getattr(s, 'profile', None)))
from api.profiles import get_hermes_home_for_profile, get_profile_runtime_env
_profile_home_path = get_hermes_home_for_profile(getattr(s, 'profile', None))
_profile_home = str(_profile_home_path)
_profile_runtime_env = get_profile_runtime_env(_profile_home_path)
except ImportError:
_profile_home = os.environ.get('HERMES_HOME', '')
_profile_runtime_env = {}
_set_thread_env(
**_profile_runtime_env,
TERMINAL_CWD=str(s.workspace),
HERMES_EXEC_ASK='1',
HERMES_SESSION_KEY=session_id,
@@ -1242,10 +1431,12 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
# The finally block re-acquires to restore — keeping critical sections short
# and preventing a deadlock where the restore would re-enter the same lock.
with _ENV_LOCK:
old_profile_env = {key: os.environ.get(key) for key in _profile_runtime_env}
old_cwd = os.environ.get('TERMINAL_CWD')
old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
old_session_key = os.environ.get('HERMES_SESSION_KEY')
old_hermes_home = os.environ.get('HERMES_HOME')
os.environ.update(_profile_runtime_env)
os.environ['TERMINAL_CWD'] = str(s.workspace)
os.environ['HERMES_EXEC_ASK'] = '1'
os.environ['HERMES_SESSION_KEY'] = session_id
@@ -1592,6 +1783,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
_cached = SESSION_AGENT_CACHE.get(session_id)
if _cached and _cached[1] == _agent_sig:
agent = _cached[0]
SESSION_AGENT_CACHE.move_to_end(session_id) # LRU: mark as recently used
logger.debug('[webui] Reusing cached agent for session %s', session_id)
if agent is not None:
@@ -1617,6 +1809,11 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
agent = _AIAgent(**_agent_kwargs)
with SESSION_AGENT_CACHE_LOCK:
SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)
SESSION_AGENT_CACHE.move_to_end(session_id) # LRU: mark as recently used
from api.config import SESSION_AGENT_CACHE_MAX
while len(SESSION_AGENT_CACHE) > SESSION_AGENT_CACHE_MAX:
evicted_sid, _ = SESSION_AGENT_CACHE.popitem(last=False)
logger.debug('[webui] Evicted LRU agent from cache: %s', evicted_sid)
logger.debug('[webui] Created new agent for session %s', session_id)
# Store agent instance for cancel/interrupt propagation
@@ -1668,6 +1865,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
if _personality_prompt:
agent.ephemeral_system_prompt = _personality_prompt
_previous_messages = list(s.messages or [])
_previous_context_messages = list(_session_context_messages(s))
# ── Periodic checkpoint during streaming (Issue #765) ──
# The agent works on an internal copy of s.messages during run_conversation()
@@ -1708,10 +1906,11 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
)
_ckpt_thread.start()
user_message = _build_native_multimodal_message(workspace_ctx, msg_text, attachments, workspace)
result = agent.run_conversation(
user_message=workspace_ctx + msg_text,
user_message=user_message,
system_message=workspace_system_msg,
conversation_history=_sanitize_messages_for_api(s.messages),
conversation_history=_sanitize_messages_for_api(_previous_context_messages),
task_id=session_id,
persist_user_message=msg_text,
)
@@ -1741,9 +1940,17 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
if _ckpt_thread is not None:
_ckpt_thread.join(timeout=15)
with _agent_lock:
s.messages = _restore_reasoning_metadata(
_result_messages = result.get('messages') or _previous_context_messages
_next_context_messages = _restore_reasoning_metadata(
_previous_context_messages,
_result_messages,
)
s.context_messages = _next_context_messages
s.messages = _merge_display_messages_after_agent_result(
_previous_messages,
result.get('messages') or s.messages,
_previous_context_messages,
_restore_reasoning_metadata(_previous_messages, _result_messages),
msg_text,
)
# Strip XML tool-call blocks from assistant message content.
# DeepSeek and some other providers emit <function_calls>...</function_calls>
@@ -1933,13 +2140,14 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
# Only tag a user message whose content relates to this turn's text
# (msg_text is the full message including the [Attached files: ...] suffix)
if attachments:
display_attachments = [_attachment_name(a) for a in attachments if _attachment_name(a)]
for m in reversed(s.messages):
if m.get('role') == 'user':
content = str(m.get('content', ''))
# Match if content is part of the sent message or vice-versa
base_text = msg_text.split('\n\n[Attached files:')[0].strip() if '\n\n[Attached files:' in msg_text else msg_text
if base_text[:60] in content or content[:60] in msg_text:
m['attachments'] = attachments
m['attachments'] = display_attachments
break
# Persist reasoning trace in the session so it survives reload.
# Must run BEFORE s.save() — otherwise the mutation lives only in
@@ -2029,6 +2237,9 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
except Exception:
logger.debug("Failed to unregister clarify callback")
with _ENV_LOCK:
for _key, _old_value in old_profile_env.items():
if _old_value is None: os.environ.pop(_key, None)
else: os.environ[_key] = _old_value
if old_cwd is None: os.environ.pop('TERMINAL_CWD', None)
else: os.environ['TERMINAL_CWD'] = old_cwd
if old_exec_ask is None: os.environ.pop('HERMES_EXEC_ASK', None)
+9 -1
View File
@@ -1,6 +1,7 @@
"""
Hermes Web UI -- File upload: multipart parser and upload handler.
"""
import mimetypes
import re as _re
import email.parser
import tempfile
@@ -80,7 +81,14 @@ def handle_upload(handler):
safe_name = _sanitize_upload_name(filename)
dest = safe_resolve_ws(workspace, safe_name)
dest.write_bytes(file_bytes)
return j(handler, {'filename': safe_name, 'path': str(dest), 'size': dest.stat().st_size})
mime = mimetypes.guess_type(safe_name)[0] or 'application/octet-stream'
return j(handler, {
'filename': safe_name,
'path': str(dest),
'size': dest.stat().st_size,
'mime': mime,
'is_image': mime.startswith('image/'),
})
except ValueError as e:
return j(handler, {'error': str(e)}, status=400)
except Exception:
+658 -23
View File
@@ -29,32 +29,32 @@ const LOCALES = {
diff_loading: 'Loading diff',
diff_error: 'Could not load patch file',
diff_too_large: 'Patch file too large to display inline',
tree_view: '트리',
raw_view: '원본',
tree_view: 'Tree',
raw_view: 'Raw',
parse_failed_note: 'parse failed',
you: 'You',
mcp_servers_title: 'MCP 서버',
mcp_servers_desc: 'config.yaml의 MCP 서버를 관리합니다.',
mcp_no_servers: '구성된 MCP 서버가 없습니다.',
mcp_add_server: '+ 서버 추가',
mcp_field_name: '서버 이름',
mcp_transport_label: '전송 유형',
mcp_field_command: '명령어',
mcp_field_args: '인수 (쉼마로 구분)',
mcp_servers_title: 'MCP Servers',
mcp_servers_desc: 'Manage MCP servers configured in config.yaml.',
mcp_no_servers: 'No MCP servers configured.',
mcp_add_server: '+ Add Server',
mcp_field_name: 'Server Name',
mcp_transport_label: 'Transport Type',
mcp_field_command: 'Command',
mcp_field_args: 'Arguments (comma-separated)',
mcp_field_url: 'URL',
mcp_field_timeout: '타임아웃 (초)',
mcp_save: '저장',
mcp_cancel: '취소',
mcp_name_required: '서버 이름이 필요합니다.',
mcp_url_required: 'HTTP 전송에는 URL이 필요합니다.',
mcp_command_required: 'stdio 전송에는 명령어가 필요합니다.',
mcp_saved: 'MCP 서버가 저장되었습니다.',
mcp_save_failed: 'MCP 서버 저장 실패.',
mcp_delete_confirm_title: 'MCP 서버 삭제',
mcp_delete_confirm_message: 'MCP 서버 «{0}»을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
mcp_deleted: 'MCP 서버가 삭제되었습니다.',
mcp_delete_failed: 'MCP 서버 삭제 실패.',
mcp_load_failed: 'MCP 서버 로드 실패.',
mcp_field_timeout: 'Timeout (seconds)',
mcp_save: 'Save',
mcp_cancel: 'Cancel',
mcp_name_required: 'Server name is required.',
mcp_url_required: 'URL is required for HTTP transport.',
mcp_command_required: 'Command is required for stdio transport.',
mcp_saved: 'MCP server saved.',
mcp_save_failed: 'Failed to save MCP server.',
mcp_delete_confirm_title: 'Delete MCP Server',
mcp_delete_confirm_message: 'Delete MCP server "{0}"? This action cannot be undone.',
mcp_deleted: 'MCP server deleted.',
mcp_delete_failed: 'Failed to delete MCP server.',
mcp_load_failed: 'Failed to load MCP servers.',
thinking: 'Thinking',
expand_all: 'Expand all',
collapse_all: 'Collapse all',
@@ -3959,6 +3959,641 @@ const LOCALES = {
composer_disabled_empty: '\u8acb\u8f38\u5165\u8a0a\u606f\u5f8c\u50b3\u9001',
},
pt: {
_lang: 'pt',
_label: 'Português',
_speech: 'pt-BR',
// boot.js
cancelling: 'Cancelando…',
cancel_failed: 'Falha ao cancelar: ',
mic_denied: 'Acesso ao microfone negado. Verifique as permissões do navegador.',
mic_no_speech: 'Nenhuma fala detectada. Tente novamente.',
mic_network: 'Reconhecimento de fala indisponível.',
mic_error: 'Erro no input de voz: ',
session_imported: 'Sessão importada',
import_failed: 'Falha na importação: ',
import_invalid_json: 'JSON inválido',
image_pasted: 'Imagem colada: ',
// messages.js
edit_message: 'Editar mensagem',
regenerate: 'Regenerar resposta',
copy: 'Copiar',
copied: 'Copiado!',
copy_failed: 'Falha ao copiar',
you: 'Você',
thinking: 'Pensando',
expand_all: 'Expandir tudo',
collapse_all: 'Recolher tudo',
edit_failed: 'Falha ao editar: ',
regen_failed: 'Falha ao regenerar: ',
reconnect_active: 'Uma resposta ainda está sendo gerada. Recarregar quando estiver pronto?',
reconnect_finished: 'Uma resposta estava em andamento quando você saiu. As mensagens podem ter atualizado.',
// approval card
approval_heading: 'Aprovação necessária',
approval_desc_prefix: 'Comando perigoso detectado',
approval_btn_once: 'Permitir uma vez',
approval_btn_once_title: 'Permitir este comando (Enter)',
approval_btn_session: 'Permitir sessão',
approval_btn_session_title: 'Permitir para esta sessão de conversa',
approval_btn_always: 'Sempre permitir',
approval_btn_always_title: 'Sempre permitir este padrão de comando',
approval_btn_deny: 'Negar',
approval_btn_deny_title: 'Negar — não executar este comando',
approval_responding: 'Respondendo…',
clarify_heading: 'Esclarecimento necessário',
clarify_hint: 'Escolha uma opção ou digite sua resposta abaixo.',
clarify_other: 'Outro',
clarify_send: 'Enviar',
clarify_input_placeholder: 'Digite sua resposta…',
clarify_responding: 'Respondendo…',
untitled: 'Sem título',
n_messages: (n) => `${n} mensagens`,
load_older_messages: '↑ Role para cima ou clique para carregar mensagens mais antigas',
queued_label: 'Envia após a resposta',
queued_count: (n) => n === 1 ? '1 na fila' : `${n} na fila`,
queued_cancel: 'Cancelar mensagem na fila',
model_unavailable: ' (indisponível)',
model_unavailable_title: 'Este modelo não está mais na sua lista de provedores',
provider_mismatch_warning: (m,p)=>`"${m}" pode não funcionar com seu provedor configurado (${p}). Enviar assim mesmo, ou execute \`hermes model\` no terminal para trocar.`,
provider_mismatch_label: 'Provedor incompatível',
model_not_found_label: 'Modelo não encontrado',
model_custom_label: 'ID de modelo customizado',
model_custom_placeholder: 'ex: openai/gpt-5.4',
model_search_placeholder: 'Buscar modelos…',
model_search_no_results: 'Nenhum modelo encontrado',
// commands.js
cmd_clear: 'Limpar mensagens da conversa',
cmd_compress: 'Comprimir manualmente o contexto (uso: /compress [tópico])',
cmd_compact_alias: 'Alias legado para /compress',
cmd_model: 'Trocar modelo (ex: /model gpt-4o)',
cmd_workspace: 'Trocar workspace por nome',
cmd_new: 'Iniciar nova sessão de chat',
cmd_usage: 'Alternar exibição de uso de tokens',
cmd_theme: 'Trocar aparência (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard)',
cmd_personality: 'Trocar personalidade do agente',
cmd_skills: 'Listar skills disponíveis do Hermes',
available_commands: 'Comandos disponíveis:',
type_slash: 'Digite / para ver comandos',
conversation_cleared: 'Conversa limpa',
command_label: 'Comando',
context_compaction_label: 'Compactação de contexto',
preserved_task_list_label: 'Lista de tarefas preservada',
reference_only_label: 'Apenas referência',
model_usage: 'Uso: /model <nome>',
no_model_match: 'Nenhum modelo correspondendo "',
switched_to: 'Trocado para ',
workspace_usage: 'Uso: /workspace <nome>',
no_workspace_match: 'Nenhum workspace correspondendo "',
switched_workspace: 'Trocado para workspace: ',
workspace_switch_failed: 'Falha ao trocar workspace: ',
new_session: 'Nova sessão criada',
compressing: 'Solicitando compressão de contexto...',
compress_running_label: 'Comprimindo',
compress_complete_label: 'Compressão completa',
auto_compress_label: 'Compressão automática',
compress_failed_label: 'Falha na compressão',
focus_label: 'Foco',
token_usage_on: 'Uso de tokens ligado',
token_usage_off: 'Uso de tokens desligado',
theme_usage: 'Uso: /theme ',
theme_set: 'Tema: ',
no_active_session: 'Nenhuma sessão ativa',
cmd_queue: 'Enfileirar mensagem para o próximo turno',
cmd_interrupt: 'Cancelar turno atual e enviar nova mensagem',
cmd_steer: 'Injetar correção no meio do turno sem interromper',
cmd_queue_no_msg: 'Uso: /queue <mensagem>',
cmd_queue_not_busy: 'Nenhuma tarefa ativa — apenas envie normalmente',
cmd_queue_confirm: 'Mensagem enfileirada',
cmd_interrupt_no_msg: 'Uso: /interrupt <mensagem>',
cmd_interrupt_confirm: 'Interrompido — enviando nova mensagem',
cmd_steer_no_msg: 'Uso: /steer <mensagem>',
cmd_steer_fallback: 'Steer indisponível — enfileirado para próximo turno',
cmd_steer_delivered: 'Steer entregue — agente verá no próximo resultado',
steer_leftover_queued: 'Steer enfileirado para próximo turno',
busy_steer_fallback: 'Steer indisponível — enfileirado para próximo turno',
busy_interrupt_confirm: 'Interrompido — enviando nova mensagem',
settings_label_busy_input_mode: 'Modo de input ocupado',
settings_desc_busy_input_mode: 'Controla o que acontece ao enviar mensagem com agente rodando. Fila espera; Interromper cancela; Steer injeta correção.',
settings_busy_input_mode_queue: 'Enfileirar follow-up',
settings_busy_input_mode_interrupt: 'Interromper turno atual',
settings_busy_input_mode_steer: 'Steer (correção no meio do turno)',
slash_skill_badge: 'Skill',
slash_skill_desc: 'Invocar esta skill',
cmd_stop: 'Parar resposta atual',
cmd_title: 'Obter ou definir título da sessão',
cmd_retry: 'Reenviar última mensagem',
cmd_undo: 'Remover última troca',
cmd_btw: 'Fazer pergunta lateral (efêmera)',
cmd_btw_usage: '/btw <pergunta> — fazer pergunta lateral',
cmd_background: 'Rodar prompt em background',
cmd_background_usage: '/background <prompt> — rodar em paralelo',
btw_asking: 'Fazendo pergunta lateral...',
btw_label: 'Pergunta lateral — não no histórico',
btw_done: 'Pergunta lateral respondida',
btw_no_answer: 'Nenhuma resposta recebida.',
btw_failed: 'Pergunta lateral falhou: ',
bg_running: 'Rodando em background...',
bg_complete: 'Tarefa de background completa',
bg_label: 'Resultado de background:',
bg_no_answer: '(sem resposta)',
bg_failed: 'Tarefa de background falhou: ',
undo_exchange: 'Desfazer última troca',
cmd_status: 'Mostrar info da sessão',
cmd_voice: 'Alternar input de microfone',
stream_stopped: 'Resposta parada.',
no_active_task: 'Nenhuma tarefa ativa para parar.',
cancel_unavailable: 'Cancelar indisponível.',
retry_failed: 'Retry falhou: ',
undo_failed: 'Undo falhou: ',
undid_n_messages: 'Removido',
undid_messages_suffix: 'mensagem(s).',
status_heading: 'Status da Sessão',
status_session_id: 'ID da Sessão',
status_title: 'Título',
status_model: 'Modelo',
status_workspace: 'Workspace',
status_personality: 'Personalidade',
status_messages: 'Mensagens',
status_agent_running: 'Agente rodando',
status_profile: 'Perfil',
status_started: 'Iniciado',
status_tokens: 'Tokens',
status_no_tokens: 'Nenhum token usado',
status_unknown: 'Desconhecido',
status_yes: 'Sim',
status_no: 'Não',
status_load_failed: 'Falha ao carregar status: ',
title_current: 'Título atual',
title_change_hint: 'Use `/title <novo nome>` para renomear.',
title_set: 'Título definido como',
cmd_webui_only_session: 'Comando indisponível para sessões CLI.',
cmd_voice_use_mic: 'Clique no botão de mic no composer.',
usage_heading: 'Uso de Tokens',
usage_default_model: 'padrão',
usage_unknown: 'desconhecido',
usage_input_tokens: 'Tokens de input',
usage_output_tokens: 'Tokens de output',
usage_total: 'Total de tokens',
usage_estimated_cost: 'Custo estimado',
usage_settings_tip: 'Nota: estimativas são aproximadas.',
usage_load_failed: 'Falha ao carregar uso: ',
usage_personality_none: 'nenhuma',
no_personalities: 'Nenhuma personalidade encontrada (adicione em ~/.hermes/personalities/)',
available_personalities: 'Personalidades disponíveis:',
personality_switch_hint: '\n\nUse `/personality <nome>` para trocar, ou `/personality none` para limpar.',
personalities_load_failed: 'Falha ao carregar personalidades',
personality_cleared: 'Personalidade limpa',
personality_set: 'Personalidade: ',
failed_colon: 'Falhou: ',
// ui.js
no_workspace: 'Nenhum workspace',
workspace_empty_no_path: 'Nenhum workspace selecionado. Configure em Configurações → Workspace.',
workspace_empty_dir: 'Este workspace está vazio.',
dialog_confirm_title: 'Confirmar ação',
dialog_prompt_title: 'Digite um valor',
dialog_confirm_btn: 'Confirmar',
// workspace.js
unsaved_confirm: 'Você tem mudanças não salvas. Descartar e navegar?',
discard: 'Descartar',
save: 'Salvar',
edit: 'Editar',
clear: 'Limpar',
create: 'Criar',
remove: 'Remover',
save_title: 'Salvar mudanças',
edit_title: 'Editar este arquivo',
saved: 'Salvo',
save_failed: 'Falha ao salvar: ',
image_load_failed: 'Não foi possível carregar imagem',
file_open_failed: 'Não foi possível abrir arquivo',
downloading: (name) => `Baixando ${name}`,
double_click_rename: 'Duplo clique para renomear',
renamed_to: 'Renomeado para ',
rename_failed: 'Falha ao renomear: ',
delete_title: 'Excluir',
delete_confirm: (name) => `Excluir ${name}?`,
deleted: 'Excluído ',
delete_failed: 'Falha ao excluir: ',
new_file_prompt: 'Nome do novo arquivo (ex: notes.md):',
project_name_prompt: 'Nome do projeto:',
created: 'Criado ',
create_failed: 'Falha ao criar: ',
new_folder_prompt: 'Nome da nova pasta:',
folder_created: 'Pasta criada ',
folder_create_failed: 'Falha ao criar pasta: ',
workspace_auto_create_folder: 'Criar pasta se não existir',
folder_add_as_space_btn: 'Adicionar como Space',
folder_add_as_space_msg: 'Adicionar esta pasta como novo space?',
folder_add_as_space_title: 'Adicionar como Space?',
remove_title: 'Remover',
empty_dir: '(vazio)',
upload_failed: 'Falha ao upload: ',
all_uploads_failed: (n) => `Todos ${n} upload(s) falharam`,
session_pin: 'Fixar conversa',
session_unpin: 'Desfixar conversa',
session_pin_desc: 'Manter esta conversa no topo',
session_unpin_desc: 'Remover dos fixados',
session_pin_failed: 'Falha ao fixar: ',
session_move_project: 'Mover para projeto',
session_move_project_desc_has: 'Mudar projeto desta conversa',
session_move_project_desc_none: 'Atribuir projeto a esta conversa',
session_archive: 'Arquivar conversa',
session_restore: 'Restaurar conversa',
session_archive_desc: 'Esconder conversa até mostrar arquivados',
session_restore_desc: 'Trazer conversa de volta à lista principal',
session_archived: 'Sessão arquivada',
session_restored: 'Sessão restaurada',
session_archive_failed: 'Falha ao arquivar: ',
session_duplicate: 'Duplicar conversa',
session_duplicate_desc: 'Criar cópia com mesmo workspace e modelo',
session_duplicated: 'Sessão duplicada',
session_duplicate_failed: 'Falha ao duplicar: ',
session_delete: 'Excluir conversa',
session_delete_desc: 'Remover permanentemente esta conversa',
// settings panel
settings_heading_title: 'Control Center',
settings_heading_subtitle: 'Preferências, ferramentas de conversa e controles do sistema.',
settings_section_conversation_title: 'Conversa',
settings_section_appearance_title: 'Aparência',
settings_section_appearance_meta: 'Tema, cores de destaque e estilo visual.',
settings_section_preferences_title: 'Preferências',
settings_section_preferences_meta: 'Padrões e comportamento UI do Hermes Web UI.',
settings_section_system_title: 'Sistema',
settings_section_system_meta: 'Versão da instância e controles de acesso.',
settings_check_now: 'Verificar agora',
settings_checking: 'Verificando…',
settings_up_to_date: 'Atualizado ✓',
settings_updates_available: '{count} atualização(ões) disponível(is)',
settings_updates_disabled: 'Verificação de updates desativada',
settings_update_check_failed: 'Falha ao verificar updates',
settings_label_workspace_panel_open: 'Manter painel workspace aberto por padrão',
settings_desc_workspace_panel_open: 'Quando ativo, o painel workspace abre automaticamente com cada nova sessão.',
open_in_browser: 'Abrir no navegador',
settings_dropdown_conversation: 'Conversa',
settings_dropdown_appearance: 'Aparência',
settings_dropdown_preferences: 'Preferências',
settings_dropdown_providers: 'Provedores',
settings_dropdown_system: 'Sistema',
settings_tab_conversation: 'Conversa',
settings_tab_appearance: 'Aparência',
settings_tab_preferences: 'Preferências',
settings_tab_system: 'Sistema',
settings_title: 'Configurações',
settings_save_btn: 'Salvar Configurações',
settings_label_model: 'Modelo Padrão',
settings_label_send_key: 'Tecla de Envio',
settings_label_theme: 'Tema',
settings_label_skin: 'Skin',
settings_label_font_size: 'Tamanho da fonte',
font_size_small: 'Pequeno',
font_size_default: 'Padrão',
font_size_large: 'Grande',
settings_label_language: 'Idioma',
settings_label_token_usage: 'Mostrar uso de tokens',
settings_label_sidebar_density: 'Densidade da sidebar',
cmd_reasoning: 'Alternar visibilidade do pensamento (mostrar/ocultar)',
settings_label_cli_sessions: 'Mostrar sessões do agente',
settings_label_sync_insights: 'Sincronizar para insights',
settings_label_check_updates: 'Verificar atualizações',
settings_label_bot_name: 'Nome do Assistente',
settings_label_password: 'Senha de Acesso',
settings_saved: 'Configurações salvas',
settings_save_failed: 'Falha ao salvar: ',
settings_load_failed: 'Falha ao carregar configurações: ',
settings_saved_pw: 'Configurações salvas — senha ativada e navegador permanece logado',
settings_saved_pw_updated: 'Configurações salvas — senha atualizada',
// login page
login_title: 'Entrar',
login_subtitle: 'Digite sua senha para continuar',
login_placeholder: 'Senha',
login_btn: 'Entrar',
login_invalid_pw: 'Senha inválida',
login_conn_failed: 'Falha na conexão',
// Sidebar & Tabs
tab_chat: 'Chat',
tab_tasks: 'Tarefas',
tab_skills: 'Skills',
tab_memory: 'Memória',
tab_workspaces: 'Spaces',
tab_profiles: 'Perfis',
tab_todos: 'Todos',
tab_settings: 'Configurações',
new_conversation: 'Nova conversa',
filter_conversations: 'Filtrar conversas...',
session_time_unknown: 'Desconhecido',
session_time_minutes_ago: (n) => `${n}m`,
session_time_hours_ago: (n) => `${n}h`,
session_time_days_ago: (n) => `${n}d`,
session_time_last_week: '1s',
session_time_bucket_today: 'Hoje',
session_time_bucket_yesterday: 'Ontem',
session_time_bucket_this_week: 'Esta semana',
session_time_bucket_last_week: 'Semana passada',
session_time_bucket_older: 'Antigo',
scheduled_jobs: 'Tarefas agendadas',
new_job: 'Nova tarefa',
loading: 'Carregando...',
search_skills: 'Buscar skills...',
new_skill: 'Nova skill',
personal_memory: 'Memória pessoal',
current_task_list: 'Lista de tarefas atual',
workspace_desc: 'Adicionar e trocar workspaces para suas sessões.',
session_meta_messages: (n) => `${n} msg${n === 1 ? '' : 's'}`,
new_profile: 'Novo perfil',
transcript: 'Transcrição',
download_transcript: 'Baixar como Markdown',
import: 'Importar',
// Settings detail
settings_label_sound: 'Som de notificação',
settings_desc_sound: 'Tocar som quando assistente finalizar resposta.',
settings_label_notifications: 'Notificações do navegador',
settings_desc_notifications: 'Mostrar notificação quando resposta completar com app em background.',
settings_desc_token_usage: 'Exibe contagem de tokens abaixo de cada resposta. Também com /usage.',
settings_sidebar_density_compact: 'Compacto',
settings_sidebar_density_detailed: 'Detalhado',
settings_desc_sidebar_density: 'Controla quanto metadado a lista de sessões mostra na sidebar.',
settings_label_auto_title_refresh: 'Atualização adaptativa de título',
settings_auto_title_refresh_off: 'Desligado',
settings_auto_title_refresh_5: 'A cada 5 trocas',
settings_auto_title_refresh_10: 'A cada 10 trocas',
settings_auto_title_refresh_20: 'A cada 20 trocas',
settings_desc_auto_title_refresh: 'Re-gera título da sessão baseado na última troca.',
settings_desc_cli_sessions: 'Mescla sessões do Hermes CLI na lista. Clique para importar.',
settings_desc_sync_insights: 'Espelha uso de tokens para state.db.',
settings_desc_check_updates: 'Mostrar banner quando versões mais novas estiverem disponíveis.',
settings_desc_bot_name: 'Nome de exibição do assistente. Padrão: Hermes.',
settings_desc_password: 'Digite nova senha para definir ou trocar. Deixe em branco para manter.',
password_placeholder: 'Digite nova senha…',
disable_auth: 'Desativar Auth',
sign_out: 'Sair',
// Providers panel
providers_tab_title: 'Provedores',
providers_section_title: 'Provedores',
providers_section_meta: 'Gerenciar API keys. Mudanças fazem efeito imediatamente.',
providers_status_configured: 'API key configurada',
providers_status_not_configured: 'Sem API key',
providers_status_oauth: 'OAuth',
providers_status_api_key: 'API key',
providers_status_not_configured_label: 'Não configurado',
providers_oauth_hint: 'Autenticado via OAuth. Sem API key necessária.',
providers_oauth_config_yaml_hint: 'Token configurado via config.yaml. Para atualizar, edite config.yaml ou rode hermes auth.',
providers_oauth_not_configured_hint: 'Não autenticado. Rode hermes auth no terminal.',
providers_save: 'Salvar',
providers_remove: 'Remover',
providers_saving: 'Salvando…',
providers_removing: 'Removendo…',
providers_enter_key: 'Por favor digite uma API key',
providers_empty: 'Nenhum provedor configurável encontrado.',
providers_key_updated: 'API key salva',
providers_key_removed: 'API key removida',
providers_key_placeholder_new: 'sk-...',
providers_key_placeholder_replace: 'Digite nova key para substituir…',
cancel: 'Cancelar',
create_job: 'Criar tarefa',
save_skill: 'Salvar skill',
editing: 'Editando',
// Empty state
empty_title: 'Como posso ajudar?',
empty_subtitle: 'Pergunte qualquer coisa, rode comandos, explore arquivos ou gerencie tarefas.',
suggest_files: 'Quais arquivos estão neste workspace?',
suggest_schedule: 'O que tenho na agenda hoje?',
suggest_plan: 'Me ajude a planejar um pequeno projeto.',
// onboarding
onboarding_badge: 'PRIMEIRO ACESSO',
onboarding_title: 'Bem-vindo ao Hermes Web UI',
onboarding_lead: 'Uma configuração rápida vai verificar Hermes, salvar provedor, escolher workspace e modelo, e opcionalmente proteger com senha.',
onboarding_back: 'Voltar',
onboarding_continue: 'Continuar',
onboarding_skip: 'Pular configuração',
onboarding_skipped: 'Configuração pulada — usando config existente.',
onboarding_open: 'Abrir Hermes',
onboarding_step_system_title: 'Verificação do sistema',
onboarding_step_system_desc: 'Verificar Hermes Agent e visibilidade da config.',
onboarding_step_setup_title: 'Configuração do provedor',
onboarding_step_setup_desc: 'Salvar config mínima do provedor Hermes.',
onboarding_step_workspace_title: 'Workspace + modelo',
onboarding_step_workspace_desc: 'Escolher padrões para novas sessões e chat.',
onboarding_step_password_title: 'Senha opcional',
onboarding_step_password_desc: 'Proteger Web UI antes de compartilhar.',
onboarding_step_finish_title: 'Finalizar',
onboarding_step_finish_desc: 'Revisar e entrar no app.',
onboarding_notice_system_ready: 'Hermes Agent parece acessível pela Web UI.',
onboarding_notice_system_unavailable: 'Hermes Agent não está totalmente disponível. Bootstrap pode instalar, mas setup do provedor pode requerer terminal.',
onboarding_check_agent: 'Hermes Agent',
onboarding_check_agent_ready: 'Detectado e importável',
onboarding_check_agent_missing: 'Ausente ou parcialmente importável',
onboarding_check_password: 'Senha',
onboarding_check_password_enabled: 'Já ativada',
onboarding_check_password_disabled: 'Não ativada ainda',
onboarding_check_provider: 'Config do provedor',
onboarding_check_provider_ready: 'Pronto para conversar',
onboarding_check_provider_partial: 'Salvo mas incompleto',
onboarding_check_provider_pending: 'Precisa verificação',
onboarding_config_file: 'Arquivo de config:',
onboarding_env_file: 'Arquivo .env:',
onboarding_unknown: 'Desconhecido',
onboarding_current_provider: 'Config atual:',
onboarding_missing_imports: 'Imports ausentes:',
onboarding_notice_setup_required: 'Escolha caminho simples de provedor aqui. OAuth avançado ainda pertence ao Hermes CLI.',
onboarding_notice_setup_already_ready: 'Setup de provedor Hermes já detectado. Pode manter ou substituir.',
onboarding_oauth_provider_ready_title: 'Provedor já autenticado',
onboarding_oauth_provider_ready_body: 'Esta instância usa provedor OAuth (<strong>{provider}</strong>) configurado via CLI. Sem API key necessária.',
onboarding_oauth_provider_not_ready_title: 'Provedor OAuth não autenticado',
onboarding_oauth_provider_not_ready_body: 'Esta instância usa <strong>{provider}</strong> com OAuth. Rode `hermes auth` no terminal.',
onboarding_oauth_switch_hint: 'Ou escolha provedor diferente abaixo para trocar para API-key:',
onboarding_notice_workspace: 'Estes valores reusam as mesmas APIs de settings do app normal.',
onboarding_workspace_label: 'Workspace',
onboarding_workspace_or_path: 'Ou digite path do workspace',
onboarding_workspace_placeholder: '/home/voce/workspace',
onboarding_provider_label: 'Modo de setup',
onboarding_quick_setup_badge: 'setup rápido',
provider_category_easy_start: 'Início fácil',
provider_category_self_hosted: 'Open / self-hosted',
provider_category_specialized: 'Especializado',
onboarding_api_key_label: 'API key',
onboarding_api_key_placeholder: 'Deixe em branco para manter key existente',
onboarding_api_key_help_prefix: 'Salvo como segredo no .env do Hermes usando',
onboarding_base_url_label: 'Base URL',
onboarding_base_url_placeholder: 'https://seu-endpoint.exemplo/v1',
onboarding_base_url_help: 'Use para endpoints OpenAI-compatible, self-hosted, LiteLLM, Ollama, LM Studio, vLLM.',
onboarding_model_label: 'Modelo padrão',
onboarding_workspace_help: 'Escolha modelo que Hermes deve usar para novos chats.',
onboarding_custom_model_placeholder: 'nome-do-seu-modelo',
onboarding_custom_model_help: 'Para endpoints customizados, digite ID exato que seu servidor espera.',
onboarding_notice_password_enabled: 'Senha já configurada. Digite nova apenas se quiser substituir.',
onboarding_notice_password_recommended: 'Opcional mas recomendado se expor UI além de localhost.',
onboarding_password_label: 'Senha (opcional)',
onboarding_password_placeholder: 'Deixe em branco para pular',
onboarding_password_help: 'Senhas são salvas via settings API e hasheadas no servidor.',
onboarding_notice_finish: 'Pode reabrir Configurações depois para mudar qualquer coisa.',
onboarding_not_set: 'Não definido',
onboarding_password_will_enable: 'Será ativada',
onboarding_password_will_replace: 'Será substituída',
onboarding_password_keep_existing: 'Manter senha atual',
onboarding_password_remains_disabled: 'Permanecerá desativada',
onboarding_password_skipped: 'Pulado por enquanto',
onboarding_finish_help: 'Finalizar guarda <code>onboarding_completed</code> em settings e leva ao app normal.',
onboarding_error_choose_workspace: 'Escolha workspace antes de continuar.',
onboarding_error_choose_model: 'Escolha modelo antes de continuar.',
onboarding_error_provider_required: 'Escolha modo de setup antes de continuar.',
onboarding_error_base_url_required: 'Base URL é necessária para endpoints customizados.',
onboarding_error_workspace_required: 'Workspace é necessário.',
onboarding_error_model_required: 'Modelo é necessário.',
onboarding_complete: 'Configuração completa',
// panel/runtime i18n
error_prefix: 'Erro: ',
not_available: 'N/D',
never: 'nunca',
add: 'Adicionar',
add_failed: 'Falha ao adicionar: ',
remove_failed: 'Falha ao remover: ',
switch_failed: 'Falha ao trocar: ',
name_required: 'Nome é necessário',
content_required: 'Conteúdo é necessário',
view: 'Ver',
dismiss: 'Dispensar',
disable: 'Desativar',
cron_no_jobs: 'Nenhuma tarefa agendada encontrada.',
cron_status_off: 'desligado',
cron_status_paused: 'pausado',
cron_status_error: 'erro',
cron_status_active: 'ativo',
cron_status_running: 'rodando…',
cron_status_needs_attention: 'precisa atenção',
cron_attention_desc: 'Esta tarefa não tem próxima execução. Scheduler pode não ter calculado.',
cron_attention_croniter_hint: 'Gateway pode não ter pacote croniter. Reinicie com cron support.',
cron_attention_resume: 'Retomar e recalcular',
cron_attention_run_once: 'Rodar uma vez agora',
cron_attention_copy_diagnostics: 'Copiar diagnóstico',
cron_diagnostics_copied: 'Diagnóstico copiado',
cron_next: 'Próxima',
cron_last: 'Última',
cron_run_now: 'Rodar agora',
cron_pause: 'Pausar',
cron_resume: 'Retomar',
cron_job_name_placeholder: 'Nome da tarefa',
cron_schedule_placeholder: 'Agendamento',
cron_prompt_placeholder: 'Prompt',
cron_last_output: 'Último output',
cron_all_runs: 'Todas execuções',
cron_hide_runs: 'Esconder execuções',
cron_no_runs_yet: '(sem execuções ainda)',
cron_schedule_required_example: 'Agendamento necessário (ex: "0 9 * * *" ou "every 1h")',
cron_schedule_required: 'Agendamento é necessário',
cron_prompt_required: 'Prompt é necessário',
cron_job_created: 'Tarefa criada',
cron_job_triggered: 'Tarefa acionada',
cron_job_paused: 'Tarefa pausada',
cron_job_resumed: 'Tarefa retomada',
cron_job_updated: 'Tarefa atualizada',
cron_delete_confirm_title: 'Excluir tarefa cron',
cron_delete_confirm_message: 'Isso não pode ser desfeito.',
cron_job_deleted: 'Tarefa excluída',
cron_completion_status: (name, status) => `Cron "${name}" ${status}`,
status_failed: 'falhou',
status_completed: 'completou',
todos_no_active: 'Nenhuma lista de tarefas ativa nesta sessão.',
clear_conversation_title: 'Limpar conversa',
clear_conversation_message: 'Limpar todas mensagens? Isso não pode ser desfeito.',
clear_failed: 'Falha ao limpar: ',
skills_no_match: 'Nenhuma skill corresponde.',
linked_files: 'Arquivos vinculados',
skill_load_failed: 'Não foi possível carregar skill: ',
skill_file_load_failed: 'Não foi possível carregar arquivo: ',
skills_empty_title: 'Selecione uma skill',
skills_empty_sub: 'Escolha skill da sidebar para ver conteúdo, ou crie nova.',
skills_edit: 'Editar',
skills_delete: 'Excluir',
skills_back_to: 'Voltar para {0}',
tasks_empty_title: 'Selecione tarefa agendada',
tasks_empty_sub: 'Escolha tarefa da sidebar para ver detalhes e execuções.',
workspaces_empty_title: 'Selecione um space',
workspaces_empty_sub: 'Escolha space da sidebar para ver arquivos e settings.',
profiles_empty_title: 'Selecione um perfil',
profiles_empty_sub: 'Escolha perfil da sidebar para ver e editar settings.',
memory_notes_label: 'memória (notas)',
memory_saved: 'Memória salva',
my_notes: 'Minhas Notas',
user_profile: 'Perfil do Usuário',
no_notes_yet: 'Nenhuma nota ainda.',
no_profile_yet: 'Nenhum perfil definido.',
// skill form
skill_name: 'Nome',
skill_category: 'Categoria',
skill_category_placeholder: 'Opcional, ex: devops',
skill_content: 'Conteúdo SKILL.md',
skill_content_placeholder: 'YAML frontmatter + markdown body',
skill_rename_not_supported: 'Renomear skill não suportado. Crie nova e exclua antiga.',
skill_metadata: 'Metadados',
// cron form
cron_name_label: 'Nome',
cron_name_placeholder: 'Opcional',
cron_schedule_label: 'Agendamento',
cron_schedule_hint: "Expressão Cron ou shorthand como 'every 1h'.",
cron_prompt_label: 'Prompt',
cron_deliver_label: 'Entregar output para',
cron_deliver_local: 'Local (salvar output apenas)',
cron_deliver_origin: 'Origem (mesmo chat)',
cron_deliver_telegram: 'Telegram',
cron_deliver_discord: 'Discord',
cron_skills_label: 'Skills',
cron_skills_placeholder: 'Adicionar skills (opcional)…',
cron_skills_edit_hint: 'Lista de skills não editável após criação.',
// workspace form
workspace_name_label: 'Nome',
workspace_name_placeholder: 'Nome amigável opcional',
workspace_path_label: 'Path',
workspace_path_required: 'Path é necessário',
workspace_path_readonly: 'Path não pode mudar. Apenas renomear.',
workspace_new_title: 'Novo space',
workspace_rename_title: 'Renomear space',
// profile form
profile_name_label: 'Nome',
profile_name_required: 'Nome é necessário',
profile_name_placeholder: 'ex: Trabalho, Pessoal',
profile_provider_label: 'Provedor',
profile_model_label: 'Modelo',
profile_model_required: 'Modelo é necessário',
profile_base_url_label: 'Base URL',
profile_base_url_placeholder: 'Opcional, ex: http://localhost:11434',
profile_api_key_label: 'API key',
profile_api_key_placeholder: 'Opcional',
manage_profiles: 'Gerenciar perfis',
profiles_load_failed: 'Falha ao carregar perfis',
profiles_busy_switch: 'Não pode trocar perfis com agente rodando',
profile_switched_new_conversation: (name) => `Trocado para perfil: ${name} — nova conversa iniciada`,
profile_switched: (name) => `Trocado para perfil: ${name}`,
profile_delete_confirm: (name) => `Excluir perfil "${name}"?`,
profile_deleted: 'Perfil excluído',
profile_delete_failed: 'Falha ao excluir perfil: ',
profile_create_failed: 'Falha ao criar perfil: ',
profile_update_failed: 'Falha ao atualizar perfil: ',
profile_already_exists: 'Perfil já existe',
// workspace switch dialog
workspace_switch_prompt_title: 'Trocar workspace',
workspace_switch_prompt_message: 'Digite path absoluto do workspace para adicionar e trocar.',
workspace_switch_prompt_confirm: 'Trocar',
workspace_switch_prompt_placeholder: '/Users/voce/projeto',
workspace_not_added: 'Workspace não adicionado',
workspace_already_saved: 'Workspace já salvo — escolha da lista',
workspace_busy_switch: 'Não pode trocar workspace com agente rodando',
discard_file_edits_title: 'Descartar edições de arquivo?',
discard_file_edits_message: 'Trocar workspace descarta edições não salvas no preview.',
workspace_switched_to: (name) => `Trocado para ${name}`,
workspace_use: 'Usar',
workspace_use_title: 'Usar nesta conversa',
workspace_add_title: 'Adicionar workspace',
// Approval card
approval_skip: 'Pular',
approval_skip_title: 'Pular este prompt de aprovação',
approval_skip_all: 'Pular todos',
approval_skip_all_title: 'Pular todos prompts de aprovação nesta sessão'
},
ko: {
_lang: 'ko',
_label: '한국어',
+13
View File
@@ -325,6 +325,7 @@
<button type="button" class="composer-terminal-action" id="btnTerminalClear" onclick="clearComposerTerminal()" data-i18n="terminal_clear">Clear</button>
<button type="button" class="composer-terminal-action" id="btnTerminalCopy" onclick="copyComposerTerminalOutput()" data-i18n="terminal_copy_output">Copy output</button>
<button type="button" class="composer-terminal-action" id="btnTerminalRestart" onclick="restartComposerTerminal()" data-i18n="terminal_restart">Restart</button>
<button type="button" class="composer-terminal-action" id="btnTerminalCollapse" onclick="collapseComposerTerminal()" data-i18n="terminal_collapse">Collapse</button>
<button type="button" class="composer-terminal-action" id="btnTerminalClose" onclick="closeComposerTerminal()" data-i18n="terminal_close">Close</button>
</div>
</div>
@@ -332,6 +333,18 @@
<div class="composer-terminal-surface" id="terminalSurface" aria-label="Workspace terminal"></div>
</div>
</div>
<div class="composer-terminal-dock" id="composerTerminalDock" hidden>
<div class="composer-terminal-dock-title">
<span class="composer-terminal-dock-dot" aria-hidden="true"></span>
<span data-i18n="terminal_title">Terminal</span>
<span class="composer-terminal-dot" aria-hidden="true">·</span>
<span id="terminalDockWorkspaceLabel"></span>
</div>
<div class="composer-terminal-actions">
<button type="button" class="composer-terminal-action" id="btnTerminalExpand" onclick="expandComposerTerminal()" data-i18n="terminal_expand">Expand</button>
<button type="button" class="composer-terminal-action" id="btnTerminalDockClose" onclick="closeComposerTerminal()" data-i18n="terminal_close">Close</button>
</div>
</div>
</div>
</div>
<!-- Queue pill outer: same positioning wrapper as .queue-card (max-width + padding) -->
+2 -2
View File
@@ -139,7 +139,7 @@ async function send(){
catch(e){if(!text){setComposerStatus(`Upload error: ${e.message}`);return;}}
const uploadedNames=uploaded.map(u=>u.name||u);
const uploadedPaths=uploaded.map(u=>u.path||u.name||u);
const uploadedPaths=uploaded.map(u=>u&&u.is_image?(u.name||u.filename||u):(u.path||u.name||u));
let msgText=text;
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploadedPaths.join(', ')}`;
else if(uploaded.length)msgText=`${text}\n\n[Attached files: ${uploadedPaths.join(', ')}]`;
@@ -182,7 +182,7 @@ async function send(){
const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({
session_id:activeSid,message:msgText,
model:S.session.model||$('modelSelect').value,workspace:S.session.workspace,
attachments:uploaded.length?uploadedNames:undefined
attachments:uploaded.length?uploaded:undefined
})});
if(startData.effective_model && S.session){
S.session.model=startData.effective_model;
+21 -4
View File
@@ -762,7 +762,7 @@
.ctx-indicator.ctx-high .ctx-ring-value{stroke:var(--error);}
.ctx-tooltip{position:absolute;right:0;bottom:calc(100% + 10px);min-width:210px;max-width:250px;padding:10px 12px;border:1px solid var(--border2);border-radius:12px;background:var(--surface);box-shadow:0 12px 30px rgba(0,0,0,.28);font-size:11px;line-height:1.45;color:var(--muted);opacity:0;transform:translateY(4px);pointer-events:none;transition:opacity .14s ease,transform .14s ease;z-index:30;}
.ctx-tooltip::after{content:'';position:absolute;right:10px;top:100%;border-width:6px 6px 0 6px;border-style:solid;border-color:var(--surface) transparent transparent transparent;}
.ctx-indicator-wrap:hover .ctx-tooltip,.ctx-indicator-wrap:focus-within .ctx-tooltip{opacity:1;transform:translateY(0);}
.ctx-indicator-wrap:hover .ctx-tooltip,.ctx-indicator-wrap:focus-within .ctx-tooltip,.ctx-tooltip-active{opacity:1;transform:translateY(0);pointer-events:auto;}
.ctx-tooltip-title{font-size:12px;font-weight:600;color:var(--text);margin-bottom:5px;}
.ctx-tooltip-line+.ctx-tooltip-line{margin-top:3px;}
.ctx-tooltip-compress{margin-top:8px;padding-top:8px;border-top:1px solid var(--border2);}
@@ -793,10 +793,23 @@
.send-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none;}
.send-btn.visible{animation:send-pop-in .18s cubic-bezier(.34,1.56,.64,1) forwards;}
.composer-terminal-panel{position:absolute;left:0;right:0;bottom:-24px;width:min(calc(100% - 64px),720px);margin:0 auto;box-sizing:border-box;overflow:hidden;pointer-events:none;z-index:1;}
.composer-terminal-panel.is-open{pointer-events:auto;}
.composer-terminal-panel.is-open,.composer-terminal-panel.is-collapsed{pointer-events:auto;}
.composer-terminal-panel[hidden]{display:none!important;}
.composer-terminal-inner{height:260px;min-height:180px;display:flex;flex-direction:column;overflow:hidden;resize:vertical;border:1px solid var(--border2);border-radius:14px;background:var(--surface);box-shadow:0 12px 32px rgba(0,0,0,.22);padding-bottom:38px;transform:translateY(100%);opacity:0;transition:transform .4s cubic-bezier(.32,.72,.16,1),opacity .25s ease;}
.composer-terminal-inner{height:var(--composer-terminal-height,260px);min-height:180px;max-height:min(520px,50vh);display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--border2);border-radius:14px;background:var(--surface);box-shadow:0 12px 32px rgba(0,0,0,.22);padding-bottom:38px;transform:translateY(100%);opacity:0;transition:transform .4s cubic-bezier(.32,.72,.16,1),opacity .25s ease;}
.composer-terminal-panel.is-open .composer-terminal-inner{transform:translateY(0);opacity:1;}
.composer-terminal-panel.is-expanding-from-dock .composer-terminal-inner{transition:opacity .18s ease;}
.composer-terminal-panel.is-collapsed{bottom:-2px;width:min(calc(100% - 112px),560px);overflow:visible;z-index:4;}
.composer-terminal-panel.is-collapsed .composer-terminal-inner{position:absolute;opacity:0;pointer-events:none;transform:translateY(100%);}
.composer-terminal-dock{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid var(--border);border-radius:13px;background:color-mix(in srgb,var(--surface) 86%,transparent);box-shadow:0 8px 22px rgba(0,0,0,.16);padding:7px 9px 7px 12px;backdrop-filter:blur(10px);transform:translateY(100%);opacity:0;transition:transform .32s cubic-bezier(.32,.72,.16,1),opacity .2s ease;}
.composer-terminal-panel.is-collapsed .composer-terminal-dock{transform:translateY(0);opacity:.94;}
.composer-terminal-dock[hidden]{display:none!important;}
.composer-terminal-dock-title{min-width:0;display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;}
.composer-terminal-dock-dot{width:7px;height:7px;border-radius:999px;background:var(--success);box-shadow:0 0 0 3px color-mix(in srgb,var(--success) 16%,transparent);flex:0 0 auto;}
#terminalDockWorkspaceLabel{min-width:0;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);text-transform:none;letter-spacing:0;font-weight:600;}
.composer-terminal-resize-handle{height:12px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;cursor:ns-resize;touch-action:none;background:linear-gradient(to bottom,rgba(255,255,255,.04),transparent);}
.composer-terminal-resize-handle::before{content:"";width:52px;height:4px;border-radius:999px;background:var(--border2);opacity:.72;transition:opacity .15s,background .15s;}
.composer-terminal-resize-handle:hover::before,.composer-terminal-resize-handle:focus-visible::before{opacity:1;background:var(--muted);}
.composer-terminal-inner.is-resizing{transition:none;user-select:none;}
.composer-terminal-header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-bottom:1px solid var(--border);background:rgba(255,255,255,.025);}
.composer-terminal-title{min-width:0;display:flex;align-items:center;gap:6px;color:var(--text);font-size:12px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;}
.composer-terminal-dot{color:var(--muted);font-weight:400;}
@@ -972,7 +985,11 @@
.ctx-indicator{width:32px;height:32px;}
.ctx-tooltip{right:-4px;min-width:190px;max-width:220px;}
.composer-terminal-panel{width:calc(100% - 20px);}
.composer-terminal-inner{height:190px;min-height:140px;border-radius:12px;padding-bottom:28px;}
.composer-terminal-panel.is-collapsed{bottom:-1px;width:calc(100% - 28px);}
.composer-terminal-inner{height:var(--composer-terminal-height,190px);min-height:140px;max-height:min(300px,44vh);border-radius:12px;padding-bottom:28px;}
.composer-terminal-dock{min-height:40px;padding:6px 7px 6px 10px;border-radius:12px;gap:8px;}
.composer-terminal-dock-title{font-size:11px;}
.composer-terminal-resize-handle{height:10px;cursor:default;}
.composer-terminal-header{padding:7px 8px;}
.composer-terminal-actions{gap:2px;overflow-x:auto;}
.composer-terminal-action{padding:5px 7px;font-size:10px;white-space:nowrap;}
+36 -10
View File
@@ -28,6 +28,7 @@ function _terminalEls(){
return {
panel:$('composerTerminalPanel'),
inner:$('composerTerminalPanel')&&$('composerTerminalPanel').querySelector('.composer-terminal-inner'),
dock:$('composerTerminalDock'),
viewport:$('terminalViewport'),
surface:$('terminalSurface'),
toggle:$('btnTerminalToggle'),
@@ -201,7 +202,7 @@ function _applyTerminalHeight(height){
handle.setAttribute('aria-valuemax',String(bounds.max));
handle.setAttribute('aria-valuenow',String(next));
}
if(TERMINAL_UI.open){
if(TERMINAL_UI.open&&!TERMINAL_UI.collapsed){
_fitTerminal();
_syncTerminalTranscriptSpace(true);
}
@@ -280,7 +281,8 @@ function _terminalIsMessagesNearBottom(el){
return el.scrollHeight-el.scrollTop-el.clientHeight<150;
}
function _syncTerminalTranscriptSpace(open){
function _syncTerminalTranscriptSpace(open,opts){
opts=opts||{};
const messages=_terminalMessagesEl();
if(!messages)return;
const wasNearBottom=_terminalIsMessagesNearBottom(messages);
@@ -289,17 +291,29 @@ function _syncTerminalTranscriptSpace(open){
messages.classList.remove('terminal-collapsed');
messages.classList.remove('terminal-expanding-from-dock');
messages.style.removeProperty('--terminal-card-height');
messages.style.removeProperty('--terminal-dock-height');
if(wasNearBottom&&typeof scrollToBottom==='function')requestAnimationFrame(scrollToBottom);
return;
}
messages.classList.add('terminal-open');
if(open==='collapsed'){
messages.classList.remove('terminal-open');
messages.classList.add('terminal-collapsed');
}else{
messages.classList.add('terminal-open');
messages.classList.remove('terminal-collapsed');
}
const measure=()=>{
if(!TERMINAL_UI.open)return;
const {panel,inner}= _terminalEls();
const h=(inner||panel)&&((inner||panel).getBoundingClientRect().height);
if(h>0)messages.style.setProperty('--terminal-card-height',Math.ceil(h+24)+'px');
const {panel,inner,dock}= _terminalEls();
const target=open==='collapsed'?(dock||panel):(inner||panel);
const h=target&&target.getBoundingClientRect().height;
if(h>0){
if(open==='collapsed')messages.style.setProperty('--terminal-dock-height',Math.ceil(h+24)+'px');
else messages.style.setProperty('--terminal-card-height',Math.ceil(h+24)+'px');
}
if(wasNearBottom&&typeof scrollToBottom==='function')scrollToBottom();
};
if(opts.immediate)measure();
requestAnimationFrame(measure);
setTimeout(measure,420);
}
@@ -317,9 +331,11 @@ function _fitTerminal(){
function _setTerminalChromeState(state){
const {panel,inner,dock,workspace,dockWorkspace}= _terminalEls();
const composerWrap=$('composerWrap');
if(!panel)return;
const collapsed=state==='collapsed';
const expanded=state==='expanded';
if(composerWrap)composerWrap.classList.toggle('terminal-dock-visible',collapsed);
panel.hidden=!(collapsed||expanded);
panel.classList.toggle('is-open',expanded);
panel.classList.toggle('is-collapsed',collapsed);
@@ -412,10 +428,15 @@ async function _startComposerTerminal(restart=false){
async function toggleComposerTerminal(force){
const next=typeof force==='boolean'?force:!TERMINAL_UI.open;
if(next){
const {panel,inner,workspace}= _terminalEls();
if(TERMINAL_UI.open){
if(TERMINAL_UI.collapsed)expandComposerTerminal();
else focusComposerTerminalInput();
return;
}
const {panel,inner}= _terminalEls();
const messages=_terminalMessagesEl();
if(!panel)return;
clearTimeout(TERMINAL_UI.closeTimer);
panel.hidden=false;
_initTerminalResizeHandle();
_resetTerminalHeightForViewport();
if(messages)messages.classList.add('terminal-expanding-from-dock');
@@ -467,7 +488,6 @@ function expandComposerTerminal(){
if(messages)void messages.offsetHeight;
_setTerminalChromeState('expanded');
_resetTerminalHeightForViewport();
_syncTerminalTranscriptSpace(true);
requestAnimationFrame(()=>{
_fitTerminal();
focusComposerTerminalInput();
@@ -502,7 +522,7 @@ async function closeComposerTerminal(sessionId,opts){
}
const {panel}= _terminalEls();
if(panel){
panel.classList.remove('is-open');
panel.classList.remove('is-open','is-collapsed','is-expanding-from-dock');
_syncTerminalTranscriptSpace(false);
clearTimeout(TERMINAL_UI.closeTimer);
TERMINAL_UI.closeTimer=setTimeout(()=>{
@@ -515,6 +535,8 @@ async function closeComposerTerminal(sessionId,opts){
}
TERMINAL_UI.open=false;
TERMINAL_UI.collapsed=false;
const composerWrap=$('composerWrap');
if(composerWrap)composerWrap.classList.remove('terminal-dock-visible');
TERMINAL_UI.sessionId=null;
TERMINAL_UI.workspace=null;
syncTerminalButton();
@@ -595,6 +617,10 @@ window.addEventListener('beforeunload',()=>{
window.addEventListener('resize',()=>{
if(!TERMINAL_UI.open)return;
if(TERMINAL_UI.collapsed){
_syncTerminalTranscriptSpace('collapsed');
return;
}
_resetTerminalHeightForViewport();
});
+27 -2
View File
@@ -696,6 +696,30 @@ function _syncCtxIndicator(usage){
}
}
// ── Touch support: toggle context tooltip on tap (#524) ──
// On mobile, hover doesn't work — allow tap on the context ring button
// to toggle the tooltip visibility so the compress affordance is reachable.
document.addEventListener('DOMContentLoaded',function(){
const wrap=document.getElementById('ctxIndicatorWrap');
const tooltip=document.getElementById('ctxTooltip');
if(!wrap||!tooltip)return;
const btn=document.getElementById('ctxIndicator');
if(!btn)return;
btn.addEventListener('click',function(e){
e.stopPropagation();
const isOpen=tooltip.classList.contains('ctx-tooltip-active');
tooltip.classList.toggle('ctx-tooltip-active',!isOpen);
tooltip.setAttribute('aria-hidden',String(isOpen));
});
// Close on outside tap
document.addEventListener('click',function(){
tooltip.classList.remove('ctx-tooltip-active');
tooltip.setAttribute('aria-hidden','true');
},{passive:true});
// Prevent tooltip click from closing itself
tooltip.addEventListener('click',function(e){e.stopPropagation();});
});
function scrollIfPinned(){
if(!_scrollPinned) return;
const el=$('messages');
@@ -2605,7 +2629,8 @@ function renderMessages(){
if(m.attachments&&m.attachments.length){
const _attachSid=(S.session&&S.session.session_id)||'';
filesHtml=`<div class="msg-files">${m.attachments.map(f=>{
const fname=f.split('/').pop()||f;
const fLabel=typeof f==='string'?f:(f&&(f.name||f.filename||f.path))||'';
const fname=String(fLabel).split('/').pop()||String(fLabel);
if(_IMAGE_EXTS.test(fname)){
// Use api/file/raw which resolves filename relative to the session workspace.
// api/media expects a full absolute path which we don't store on the client side.
@@ -3892,7 +3917,7 @@ async function uploadPendingFiles(){
names.push({name: data.dest, path: data.dest, extracted: data.extracted});
if(typeof loadDir==='function')loadDir(S.currentDir||'.');
}else{
names.push({name: data.filename, path: data.path});
names.push({name: data.filename, path: data.path, mime: data.mime, size: data.size, is_image: !!data.is_image});
}
}catch(e){failures++;setStatus(`\u274c ${t('upload_failed')}${f.name} \u2014 ${e.message}`);}
bar.style.width=`${Math.round((i+1)/total*100)}%`;
+116 -1
View File
@@ -33,14 +33,129 @@ def test_terminal_surface_uses_composer_flyout_card_pattern():
flyout = html.split('<div class="composer-flyout">', 1)[1].split('<div class="queue-pill-outer">', 1)[0]
assert 'id="composerTerminalPanel"' in flyout
assert 'class="composer-terminal-inner"' in flyout
assert 'id="composerTerminalDock"' in flyout
assert 'id="terminalResizeHandle"' in flyout
assert 'id="composerTerminalPanel"' not in html.split('<div class="queue-pill-outer">', 1)[1]
assert ".composer-terminal-panel{position:absolute" in style_css
assert "bottom:-24px" in style_css
assert "width:min(calc(100% - 64px),720px)" in style_css
assert ".composer-terminal-inner{height:260px" in style_css
assert ".composer-wrap.terminal-dock-visible .composer-flyout{z-index:4" in style_css
assert ".composer-terminal-panel.is-collapsed{bottom:-2px;width:min(calc(100% - 112px),560px);overflow:visible;z-index:4" in style_css
assert ".composer-terminal-panel.is-expanding-from-dock .composer-terminal-inner{transition:opacity .18s ease" in style_css
assert ".messages.terminal-expanding-from-dock{transition:none!important" in style_css
assert ".composer-terminal-dock{min-height:42px" in style_css
assert ".composer-terminal-inner{height:var(--composer-terminal-height,260px)" in style_css
assert "transform:translateY(100%)" in style_css
def test_terminal_uses_controlled_desktop_resize_handle():
html = _read("static/index.html")
style_css = _read("static/style.css")
terminal_js = _read("static/terminal.js")
assert 'class="composer-terminal-resize-handle"' in html
assert 'role="separator"' in html
assert 'aria-orientation="horizontal"' in html
terminal_inner_rule = style_css.split(".composer-terminal-inner{", 1)[1].split("}", 1)[0]
assert "resize:" not in terminal_inner_rule
assert "cursor:ns-resize" in style_css
assert "const TERMINAL_HEIGHT_DEFAULT=260" in terminal_js
assert "const TERMINAL_HEIGHT_MIN=180" in terminal_js
assert "const TERMINAL_HEIGHT_MAX=520" in terminal_js
assert "max:Math.max(min,Math.min(hardMax,maxByViewport))" in terminal_js
def test_terminal_resize_path_refits_backend_and_transcript_space():
terminal_js = _read("static/terminal.js")
assert "function _applyTerminalHeight" in terminal_js
apply_block = terminal_js.split("function _applyTerminalHeight", 1)[1].split("function _resetTerminalHeightForViewport", 1)[0]
assert "_fitTerminal();" in apply_block
assert "_syncTerminalTranscriptSpace(true);" in apply_block
assert "function _moveTerminalHeightResize" in terminal_js
assert "_applyTerminalHeight(TERMINAL_UI.resizeStartHeight+(TERMINAL_UI.resizeStartY-ev.clientY))" in terminal_js
assert "handle.addEventListener('pointerdown',_startTerminalHeightResize)" in terminal_js
assert "handle.addEventListener('pointermove',_moveTerminalHeightResize)" in terminal_js
assert "clearTimeout(TERMINAL_UI.resizeTimer)" in terminal_js
assert "api('/api/terminal/resize'" in terminal_js
def test_terminal_open_reserves_transcript_space():
style_css = _read("static/style.css")
terminal_js = _read("static/terminal.js")
assert ".messages.terminal-open{padding-bottom:var(--terminal-card-height" in style_css
assert ".messages.terminal-collapsed{padding-bottom:var(--terminal-dock-height" in style_css
assert "scroll-padding-bottom:var(--terminal-card-height" in style_css
assert "classList.add('terminal-open')" in terminal_js
assert "classList.add('terminal-collapsed')" in terminal_js
assert "classList.remove('terminal-open')" in terminal_js
assert "classList.remove('terminal-collapsed')" in terminal_js
assert "messages.style.setProperty('--terminal-card-height'" in terminal_js
assert "messages.style.setProperty('--terminal-dock-height'" in terminal_js
assert "messages.style.removeProperty('--terminal-card-height')" in terminal_js
assert "messages.style.removeProperty('--terminal-dock-height')" in terminal_js
assert "function _terminalIsMessagesNearBottom" in terminal_js
assert "scrollToBottom" in terminal_js
def test_terminal_initial_open_settles_transcript_space_before_reveal():
terminal_js = _read("static/terminal.js")
open_block = terminal_js.split("async function toggleComposerTerminal", 1)[1].split("function collapseComposerTerminal", 1)[0]
assert "messages.classList.add('terminal-expanding-from-dock')" in open_block
assert "_syncTerminalTranscriptSpace(true,{immediate:true});" in open_block
assert "void messages.offsetHeight;" in open_block
assert "panel.classList.add('is-open')" in open_block
assert "messages.classList.remove('terminal-expanding-from-dock')" in open_block
assert open_block.index("_syncTerminalTranscriptSpace(true,{immediate:true});") < open_block.index("panel.classList.add('is-open')")
assert open_block.index("void messages.offsetHeight;") < open_block.index("panel.classList.add('is-open')")
def test_terminal_collapsed_state_preserves_pty_and_output_surface():
html = _read("static/index.html")
terminal_js = _read("static/terminal.js")
assert 'id="btnTerminalCollapse"' in html
assert 'onclick="collapseComposerTerminal()"' in html
assert 'id="btnTerminalExpand"' in html
assert 'onclick="expandComposerTerminal()"' in html
assert 'id="btnTerminalDockClose"' in html
assert 'onclick="closeComposerTerminal()"' in html
assert "collapsed:false" in terminal_js
collapse_block = terminal_js.split("function collapseComposerTerminal", 1)[1].split("function expandComposerTerminal", 1)[0]
assert "api('/api/terminal/close'" not in collapse_block
assert "_disposeXterm" not in collapse_block
assert "_setTerminalChromeState('collapsed')" in collapse_block
assert "composerWrap.classList.toggle('terminal-dock-visible',collapsed)" in terminal_js
expand_block = terminal_js.split("function expandComposerTerminal", 1)[1].split("function _disposeXterm", 1)[0]
assert "_setTerminalChromeState('expanded')" in expand_block
assert "panel.classList.add('is-expanding-from-dock')" in expand_block
assert "panel.classList.remove('is-expanding-from-dock')" in expand_block
assert "messages.classList.add('terminal-expanding-from-dock')" in expand_block
assert "messages.classList.remove('terminal-expanding-from-dock')" in expand_block
assert "_syncTerminalTranscriptSpace(true,{immediate:true});" in expand_block
assert "void messages.offsetHeight;" in expand_block
assert expand_block.index("_syncTerminalTranscriptSpace(true,{immediate:true});") < expand_block.index("_setTerminalChromeState('expanded')")
assert expand_block.index("void messages.offsetHeight;") < expand_block.index("_setTerminalChromeState('expanded')")
assert "_resetTerminalHeightForViewport();" in expand_block
assert "focusComposerTerminalInput();" in expand_block
close_block = terminal_js.split("async function closeComposerTerminal", 1)[1].split("async function restartComposerTerminal", 1)[0]
assert "api('/api/terminal/close'" in close_block
assert "_disposeXterm();" in close_block
def test_terminal_slash_command_expands_existing_collapsed_terminal():
commands_js = _read("static/commands.js")
terminal_js = _read("static/terminal.js")
assert "await toggleComposerTerminal(true)" in commands_js
toggle_block = terminal_js.split("async function toggleComposerTerminal", 1)[1].split("function collapseComposerTerminal", 1)[0]
assert "if(TERMINAL_UI.open)" in toggle_block
assert "if(TERMINAL_UI.collapsed)expandComposerTerminal();" in toggle_block
assert "else focusComposerTerminalInput();" in toggle_block
def test_terminal_v1_does_not_expose_send_to_chat_action():
html = _read("static/index.html")
terminal_js = _read("static/terminal.js")
@@ -0,0 +1,210 @@
from api.models import Session
import contextlib
from api.streaming import (
_merge_display_messages_after_agent_result,
_sanitize_messages_for_api,
_session_context_messages,
)
def test_session_persists_model_context_separately_from_display_transcript(tmp_path, monkeypatch):
"""Compacted model context must not replace the visible WebUI transcript."""
state_dir = tmp_path / "state"
session_dir = state_dir / "sessions"
session_dir.mkdir(parents=True)
import api.models as models
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", state_dir / "session_index.json")
original_display = [
{"role": "user", "content": "original long prompt"},
{"role": "assistant", "content": "original detailed answer"},
]
compacted_context = [
{
"role": "user",
"content": "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted.",
},
{"role": "user", "content": "continue from here"},
{"role": "assistant", "content": "continued response"},
]
session = Session(
session_id="issue1217",
workspace=str(tmp_path),
messages=original_display,
context_messages=compacted_context,
)
session.save(touch_updated_at=False)
reloaded = Session.load("issue1217")
assert reloaded.messages == original_display
assert reloaded.context_messages == compacted_context
assert _session_context_messages(reloaded) == compacted_context
assert _sanitize_messages_for_api(_session_context_messages(reloaded)) == compacted_context
def test_compacted_agent_result_keeps_old_prompts_and_appends_current_turn():
previous_display = [
{"role": "user", "content": "first prompt that must remain visible"},
{"role": "assistant", "content": "first answer"},
{"role": "user", "content": "second prompt that must remain visible"},
{"role": "assistant", "content": "second answer"},
]
previous_context = list(previous_display)
compacted_result = [
{
"role": "user",
"content": "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted.",
},
{"role": "user", "content": "new question after compaction"},
{"role": "assistant", "content": "new answer after compaction"},
]
merged = _merge_display_messages_after_agent_result(
previous_display,
previous_context,
compacted_result,
"new question after compaction",
)
assert [m["content"] for m in merged] == [
"first prompt that must remain visible",
"first answer",
"second prompt that must remain visible",
"second answer",
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted.",
"new question after compaction",
"new answer after compaction",
]
def test_append_only_agent_result_preserves_normal_delta_behavior():
previous_display = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
previous_context = list(previous_display)
result_messages = previous_context + [
{"role": "user", "content": "what next?"},
{"role": "assistant", "content": "next answer"},
]
merged = _merge_display_messages_after_agent_result(
previous_display,
previous_context,
result_messages,
"what next?",
)
assert merged == result_messages
def test_repeated_user_text_after_compaction_is_not_dropped():
previous_display = [
{"role": "user", "content": "continue"},
{"role": "assistant", "content": "old answer"},
]
previous_context = list(previous_display)
compacted_result = [
{"role": "user", "content": "[CONTEXT COMPACTION — REFERENCE ONLY] summary"},
{"role": "user", "content": "continue"},
{"role": "assistant", "content": "new answer"},
]
merged = _merge_display_messages_after_agent_result(
previous_display,
previous_context,
compacted_result,
"continue",
)
assert [m["content"] for m in merged] == [
"continue",
"old answer",
"[CONTEXT COMPACTION — REFERENCE ONLY] summary",
"continue",
"new answer",
]
def test_session_context_falls_back_to_display_messages_for_legacy_sessions(tmp_path):
messages = [
{"role": "user", "content": "legacy prompt"},
{"role": "assistant", "content": "legacy answer"},
]
session = Session(session_id="legacy1217", workspace=str(tmp_path), messages=messages)
assert session.context_messages == []
assert _session_context_messages(session) == messages
def test_retry_truncates_model_context_when_it_is_separate(monkeypatch, tmp_path):
import api.session_ops as session_ops
session = Session(
session_id="retry1217",
workspace=str(tmp_path),
messages=[
{"role": "user", "content": "visible one"},
{"role": "assistant", "content": "visible two"},
{"role": "user", "content": "visible three"},
{"role": "assistant", "content": "visible four"},
],
context_messages=[
{"role": "user", "content": "[CONTEXT COMPACTION — REFERENCE ONLY] summary"},
{"role": "user", "content": "visible three"},
{"role": "assistant", "content": "visible four"},
],
)
saved = []
session.save = lambda *args, **kwargs: saved.append(True)
monkeypatch.setattr(session_ops, "get_session", lambda sid: session)
monkeypatch.setattr(session_ops, "SESSIONS", {session.session_id: session})
monkeypatch.setattr(session_ops, "_get_session_agent_lock", lambda sid: contextlib.nullcontext())
result = session_ops.retry_last(session.session_id)
assert result["last_user_text"] == "visible three"
assert [m["content"] for m in session.messages] == ["visible one", "visible two"]
assert [m["content"] for m in session.context_messages] == [
"[CONTEXT COMPACTION — REFERENCE ONLY] summary"
]
assert saved
def test_undo_truncates_model_context_when_it_is_separate(monkeypatch, tmp_path):
import api.session_ops as session_ops
session = Session(
session_id="undo1217",
workspace=str(tmp_path),
messages=[
{"role": "user", "content": "visible one"},
{"role": "assistant", "content": "visible two"},
{"role": "user", "content": "visible three"},
{"role": "assistant", "content": "visible four"},
],
context_messages=[
{"role": "user", "content": "[CONTEXT COMPACTION — REFERENCE ONLY] summary"},
{"role": "user", "content": "visible three"},
{"role": "assistant", "content": "visible four"},
],
)
saved = []
session.save = lambda *args, **kwargs: saved.append(True)
monkeypatch.setattr(session_ops, "get_session", lambda sid: session)
monkeypatch.setattr(session_ops, "SESSIONS", {session.session_id: session})
monkeypatch.setattr(session_ops, "_get_session_agent_lock", lambda sid: contextlib.nullcontext())
result = session_ops.undo_last(session.session_id)
assert result["removed_count"] == 2
assert [m["content"] for m in session.messages] == ["visible one", "visible two"]
assert [m["content"] for m in session.context_messages] == [
"[CONTEXT COMPACTION — REFERENCE ONLY] summary"
]
assert saved
@@ -104,3 +104,6 @@ class TestProviderModelsCompleteness:
def test_has_openrouter(self):
# openrouter uses _FALLBACK_MODELS, not _PROVIDER_MODELS
pass # intentionally no assertion
def test_has_minimax_cn(self):
assert "minimax-cn" in _PROVIDER_MODELS_KEYS
+3 -3
View File
@@ -318,8 +318,8 @@ class TestIssue765FollowupHardening:
)
stop_idx = src.find("if _checkpoint_stop is not None:\n _checkpoint_stop.set()")
join_idx = src.find("if _ckpt_thread is not None:\n _ckpt_thread.join(timeout=15)")
lock_idx = src.find("with _agent_lock:\n s.messages = _restore_reasoning_metadata(")
save_idx = src.find("s.messages = _restore_reasoning_metadata(")
lock_idx = src.find("with _agent_lock:\n _result_messages =")
save_idx = src.find("s.context_messages = _next_context_messages")
assert stop_idx != -1, "Success path must stop the checkpoint thread"
assert join_idx != -1, "Success path must join the checkpoint thread"
@@ -338,7 +338,7 @@ class TestIssue765FollowupHardening:
src = (Path(__file__).parent.parent / "api" / "streaming.py").read_text(
encoding="utf-8"
)
outer_lock_idx = src.find("with _agent_lock:\n s.messages = _restore_reasoning_metadata(")
outer_lock_idx = src.find("with _agent_lock:\n _result_messages =")
silent_failure_idx = src.find("if not _assistant_added and not _token_sent:")
inner_lock_idx = src.find("with _agent_lock:", outer_lock_idx + 1)
compression_idx = src.find("# ── Handle context compression side effects ──")
+93 -1
View File
@@ -3,7 +3,7 @@ Tests for MiniMax provider support in the model/provider discovery layer.
Covers:
- MiniMax models appear in the fallback model list
- MINIMAX_API_KEY env var is scanned and detected from os.environ
- MINIMAX_API_KEY / MINIMAX_CN_API_KEY env vars are scanned and detected
- @minimax: provider hint routing works correctly
- minimax/MiniMax-M2.7 (slash format) is routed via openrouter when active provider differs
"""
@@ -12,6 +12,36 @@ import pytest
import api.config as config
def _force_env_fallback(monkeypatch):
"""Force get_available_models() down the explicit env-var fallback path."""
import builtins
real_import = builtins.__import__
def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
if name in ("hermes_cli.models", "hermes_cli.auth"):
raise ImportError(name)
return real_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(builtins, "__import__", fake_import)
def _run_available_models_with_cfg(monkeypatch, tmp_path, cfg):
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json")
monkeypatch.setattr(config, "_get_config_path", lambda: tmp_path / "missing-config.yaml")
config.cfg.clear()
config.cfg.update(cfg)
config._cfg_mtime = 0.0
try:
return config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
@pytest.fixture(autouse=True)
def _isolate_models_cache():
"""Invalidate the models TTL cache before and after every test in this file."""
@@ -91,6 +121,19 @@ def test_minimax_provider_models_has_highspeed():
)
def test_minimax_cn_provider_models_match_hermes_agent_catalog():
"""minimax-cn must have its own static catalog so an empty config provider still shows models."""
models = config._PROVIDER_MODELS.get('minimax-cn', [])
ids = [m['id'] for m in models]
assert ids == [
'MiniMax-M2.7',
'MiniMax-M2.5',
'MiniMax-M2.1',
'MiniMax-M2',
]
assert config._PROVIDER_DISPLAY.get('minimax-cn') == 'MiniMax (China)'
# ── MINIMAX_API_KEY env var detection ─────────────────────────────────────────
def test_minimax_api_key_in_env_scan_tuple():
@@ -132,6 +175,55 @@ def test_minimax_detected_from_os_environ(monkeypatch):
config.cfg.update(old_cfg)
def test_minimax_cn_detected_from_os_environ(monkeypatch, tmp_path):
"""MINIMAX_CN_API_KEY should show MiniMax (China), not the global MiniMax provider."""
_force_env_fallback(monkeypatch)
monkeypatch.delenv('MINIMAX_API_KEY', raising=False)
monkeypatch.setenv('MINIMAX_CN_API_KEY', 'test-cn-key-from-env')
result = _run_available_models_with_cfg(monkeypatch, tmp_path, {'model': {}})
groups = {g['provider_id']: g for g in result['groups']}
assert 'minimax-cn' in groups, f"minimax-cn group missing: {groups.keys()}"
assert groups['minimax-cn']['provider'] == 'MiniMax (China)'
assert {m['id'] for m in groups['minimax-cn']['models']} == {
'MiniMax-M2.7',
'MiniMax-M2.5',
'MiniMax-M2.1',
'MiniMax-M2',
}
assert 'minimax' not in groups, (
"MINIMAX_CN_API_KEY must not be collapsed into the global minimax provider"
)
def test_minimax_cn_empty_config_provider_gets_static_models(monkeypatch, tmp_path):
"""providers.minimax-cn: {} should still render a populated model group."""
_force_env_fallback(monkeypatch)
monkeypatch.delenv('MINIMAX_API_KEY', raising=False)
monkeypatch.delenv('MINIMAX_CN_API_KEY', raising=False)
result = _run_available_models_with_cfg(
monkeypatch,
tmp_path,
{
'model': {'provider': 'minimax-cn', 'default': 'MiniMax-M2.7'},
'providers': {'minimax-cn': {}},
},
)
groups = {g['provider_id']: g for g in result['groups']}
assert 'minimax-cn' in groups, f"minimax-cn group missing: {groups.keys()}"
assert groups['minimax-cn']['models'], "minimax-cn group must not be empty"
def test_minimax_cn_key_can_be_managed_from_provider_settings():
"""Provider settings should use the Hermes Agent env var for minimax-cn."""
from api.providers import _PROVIDER_ENV_VAR
assert _PROVIDER_ENV_VAR.get('minimax-cn') == 'MINIMAX_CN_API_KEY'
# ── Model routing ─────────────────────────────────────────────────────────────
def test_provider_hint_minimax_m2_7():
+6 -5
View File
@@ -365,8 +365,9 @@ def test_unknown_providers_do_not_inherit_default_model(monkeypatch):
"""Detected providers without their own model catalog must not be filled
with the global default_model placeholder.
Regression guard for the bug where Alibaba / Minimax-Cn ended up showing
gpt-5.4-mini even though those providers do not serve it.
Regression guard for the bug where unknown providers ended up showing
gpt-5.4-mini even though those providers do not serve it. Minimax-Cn is
now known and should show its own catalog instead.
"""
import sys, types
@@ -392,12 +393,12 @@ def test_unknown_providers_do_not_inherit_default_model(monkeypatch):
assert 'Alibaba' not in groups, (
f"Alibaba should not inherit the default model placeholder: {groups}"
)
assert 'Minimax-Cn' not in groups, (
f"Minimax-Cn should not inherit the default model placeholder: {groups}"
assert 'MiniMax (China)' in groups, (
f"Minimax-Cn should render its own static catalog: {groups}"
)
assert not any(
norm(mid) == 'gpt-5.4-mini'
for mid in groups.get('Alibaba', []) + groups.get('Minimax-Cn', [])
for mid in groups.get('Alibaba', []) + groups.get('MiniMax (China)', [])
), (
f"Unknown provider groups still inherited the default model: {groups}"
)
+380
View File
@@ -0,0 +1,380 @@
"""Tests for native multimodal image attachment support (PR #1229).
Verifies _build_native_multimodal_message, _normalize_chat_attachments,
and _attachment_name from api.streaming / api.routes behave correctly
across the workspace-path safety, size ceiling, multi-image, MIME, and
fallback cases the maintainer asked about.
"""
import base64
import os
import struct
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from api.streaming import (
_attachment_name,
_build_native_multimodal_message,
_NATIVE_IMAGE_MAX_BYTES,
)
from api.routes import _normalize_chat_attachments
# ── Helpers ─────────────────────────────────────────────────────────────────
def _make_png(path: Path, size: int = 0) -> Path:
"""Write a minimal valid PNG to *path* (IHDR + IDAT + IEND)."""
if size <= 0:
# smallest valid PNG (67 bytes)
data = (
b'\x89PNG\r\n\x1a\n'
b'\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde'
b'\x00\x00\x00\x0bIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18\xd8N'
b'\x00\x00\x00\x00IEND\xaeB`\x82'
)
else:
data = b'\x89PNG\r\n\x1a\n' + b'\x00' * (size - 8)
path.write_bytes(data)
return path
def _make_jpeg(path: Path, size: int = 107) -> Path:
"""Write a tiny but valid JPEG."""
data = (
b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00'
b'\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08\n'
b'\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a'
b'\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\'9=82<.342'
b'\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x01\x11\x00'
b'\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b'
b'\xff\xda\x00\x08\x01\x01\x00\x00?\x00\x7f\x00'
b'\xff\xd9'
)
if size > len(data):
data += b'\x00' * (size - len(data))
path.write_bytes(data[:size] if size < len(data) else data)
return path
# ── _attachment_name ────────────────────────────────────────────────────────
class TestAttachmentName:
def test_dict_with_name(self):
assert _attachment_name({'name': 'photo.png', 'path': '/tmp/x'}) == 'photo.png'
def test_dict_with_filename_fallback(self):
assert _attachment_name({'filename': 'img.jpg'}) == 'img.jpg'
def test_dict_with_path_fallback(self):
assert _attachment_name({'path': '/ws/snap.png'}) == '/ws/snap.png'
def test_string_attachment(self):
assert _attachment_name('readme.md') == 'readme.md'
def test_empty_attachment(self):
assert _attachment_name({}) == ''
def test_none_attachment(self):
assert _attachment_name(None) == ''
# ── _normalize_chat_attachments ─────────────────────────────────────────────
class TestNormalizeChatAttachments:
def test_legacy_string_list(self):
result = _normalize_chat_attachments(['a.png', 'b.txt'])
assert result == [
{'name': 'a.png', 'path': '', 'mime': ''},
{'name': 'b.txt', 'path': '', 'mime': ''},
]
def test_dict_with_mime_and_is_image(self):
result = _normalize_chat_attachments([{
'name': 'photo.png', 'path': '/ws/photo.png',
'mime': 'image/png', 'size': 1234, 'is_image': True,
}])
assert result == [{
'name': 'photo.png', 'path': '/ws/photo.png',
'mime': 'image/png', 'size': 1234, 'is_image': True,
}]
def test_dict_missing_fields_defaults(self):
result = _normalize_chat_attachments([{'path': '/x'}])
assert result == [{'name': '/x', 'path': '/x', 'mime': ''}]
def test_mixed_list(self):
result = _normalize_chat_attachments([
'old.txt',
{'name': 'new.png', 'path': '/ws/new.png', 'mime': 'image/png'},
])
assert len(result) == 2
assert result[0] == {'name': 'old.txt', 'path': '', 'mime': ''}
assert result[1]['name'] == 'new.png'
def test_empty_list(self):
assert _normalize_chat_attachments([]) == []
def test_not_a_list(self):
assert _normalize_chat_attachments(None) == []
assert _normalize_chat_attachments('abc') == []
# ── _build_native_multimodal_message ────────────────────────────────────────
class TestBuildNativeMultimodalMessage:
def test_no_attachments_returns_string(self):
result = _build_native_multimodal_message('[WS: x]\n', 'describe', [], '/ws')
assert result == '[WS: x]\ndescribe'
def test_single_image_in_workspace(self):
with TemporaryDirectory() as d:
root = Path(d)
img = root / 'pic.png'
_make_png(img)
atts = _normalize_chat_attachments([{
'name': 'pic.png', 'path': str(img),
'mime': 'image/png', 'size': img.stat().st_size, 'is_image': True,
}])
result = _build_native_multimodal_message('[WS]\n', 'look', atts, str(root))
assert isinstance(result, list)
assert result[0] == {'type': 'text', 'text': '[WS]\nlook'}
assert len(result) == 2
assert result[1]['type'] == 'image_url'
url = result[1]['image_url']['url']
assert url.startswith('data:image/png;base64,')
decoded = base64.b64decode(url.split(',', 1)[1])
assert decoded[:4] == b'\x89PNG'
def test_jpeg_image_in_workspace(self):
with TemporaryDirectory() as d:
root = Path(d)
img = root / 'photo.jpeg'
_make_jpeg(img)
atts = _normalize_chat_attachments([{
'name': 'photo.jpeg', 'path': str(img),
'mime': 'image/jpeg', 'size': img.stat().st_size, 'is_image': True,
}])
result = _build_native_multimodal_message('', 'hi', atts, str(root))
assert result[1]['image_url']['url'].startswith('data:image/jpeg;base64,')
def test_multiple_images_become_multiple_parts(self):
with TemporaryDirectory() as d:
root = Path(d)
img1 = root / 'a.png'
img2 = root / 'b.png'
_make_png(img1)
_make_png(img2)
atts = _normalize_chat_attachments([
{'name': 'a.png', 'path': str(img1), 'mime': 'image/png', 'size': img1.stat().st_size, 'is_image': True},
{'name': 'b.png', 'path': str(img2), 'mime': 'image/png', 'size': img2.stat().st_size, 'is_image': True},
])
result = _build_native_multimodal_message('', 'multi', atts, str(root))
image_parts = [p for p in result if p['type'] == 'image_url']
assert len(image_parts) == 2
def test_non_image_attachment_stays_text_fallback(self):
with TemporaryDirectory() as d:
root = Path(d)
doc = root / 'notes.txt'
doc.write_text('hello')
atts = _normalize_chat_attachments([{
'name': 'notes.txt', 'path': str(doc),
'mime': 'text/plain', 'size': doc.stat().st_size, 'is_image': False,
}])
result = _build_native_multimodal_message('[WS]\n', 'read', atts, str(root))
assert isinstance(result, str)
assert 'read' in result
def test_outside_workspace_path_rejected(self):
with TemporaryDirectory() as d:
root = Path(d)
outside = Path(d) / '..' / 'outside.png'
outside = outside.resolve()
_make_png(outside)
atts = _normalize_chat_attachments([{
'name': 'outside.png', 'path': str(outside),
'mime': 'image/png', 'size': outside.stat().st_size, 'is_image': True,
}])
result = _build_native_multimodal_message('', 'hi', atts, str(root))
# Should fall back to string; outside path is rejected
assert isinstance(result, str)
def test_symlink_inside_workspace_resolved(self):
"""Symlink inside workspace pointing to workspace file is allowed."""
with TemporaryDirectory() as d:
root = Path(d)
real_file = root / 'real.png'
_make_png(real_file)
link = root / 'link.png'
os.symlink(str(real_file), str(link))
atts = _normalize_chat_attachments([{
'name': 'link.png', 'path': str(link),
'mime': 'image/png', 'size': real_file.stat().st_size, 'is_image': True,
}])
result = _build_native_multimodal_message('', 'hi', atts, str(root))
# Symlink resolves inside workspace, so it should be accepted
assert isinstance(result, list)
assert result[1]['type'] == 'image_url'
def test_symlink_pointing_outside_workspace_rejected(self):
"""Symlink inside workspace pointing outside must be rejected by .resolve()."""
with TemporaryDirectory() as d:
root = Path(d)
outside_file = Path(d) / '..' / 'escape.png'
outside_file = outside_file.resolve()
_make_png(outside_file)
link = root / 'trap.link'
os.symlink(str(outside_file), str(link))
atts = _normalize_chat_attachments([{
'name': 'trap.link', 'path': str(link),
'mime': 'image/png', 'size': outside_file.stat().st_size, 'is_image': True,
}])
result = _build_native_multimodal_message('', 'hi', atts, str(root))
assert isinstance(result, str)
def test_size_above_cap_rejected(self):
"""Images larger than _NATIVE_IMAGE_MAX_BYTES must not be included."""
with TemporaryDirectory() as d:
root = Path(d)
huge = root / 'huge.png'
_make_png(huge, size=_NATIVE_IMAGE_MAX_BYTES + 1)
atts = _normalize_chat_attachments([{
'name': 'huge.png', 'path': str(huge),
'mime': 'image/png', 'size': huge.stat().st_size, 'is_image': True,
}])
result = _build_native_multimodal_message('', 'hi', atts, str(root))
assert isinstance(result, str)
def test_missing_path_skipped(self):
with TemporaryDirectory() as d:
root = Path(d)
atts = _normalize_chat_attachments([{
'name': 'ghost.png', 'path': str(root / 'no-such.png'),
'mime': 'image/png', 'is_image': True,
}])
result = _build_native_multimodal_message('', 'hi', atts, str(root))
assert isinstance(result, str)
def test_no_mime_guessed_from_extension(self):
with TemporaryDirectory() as d:
root = Path(d)
img = root / 'pic.png'
_make_png(img)
atts = _normalize_chat_attachments([{
'name': 'pic.png', 'path': str(img),
'mime': '', 'size': img.stat().st_size, 'is_image': True,
}])
result = _build_native_multimodal_message('', 'hi', atts, str(root))
assert isinstance(result, list)
assert result[1]['image_url']['url'].startswith('data:image/png;base64,')
def test_mixed_image_and_nonimage(self):
"""Non-image is skipped; image still goes through."""
with TemporaryDirectory() as d:
root = Path(d)
img = root / 'pic.png'
_make_png(img)
doc = root / 'readme.md'
doc.write_text('# hello')
atts = _normalize_chat_attachments([
{'name': 'pic.png', 'path': str(img), 'mime': 'image/png', 'size': img.stat().st_size, 'is_image': True},
{'name': 'readme.md', 'path': str(doc), 'mime': 'text/markdown', 'size': doc.stat().st_size, 'is_image': False},
])
result = _build_native_multimodal_message('', 'hi', atts, str(root))
assert isinstance(result, list)
image_parts = [p for p in result if p['type'] == 'image_url']
assert len(image_parts) == 1
assert 'hi' in result[0]['text']
def test_upload_result_structure_roundtrip(self):
"""Simulate the full flow: upload result → normalize → build message."""
with TemporaryDirectory() as d:
root = Path(d)
img = root / 'screenshot.png'
_make_png(img)
# what /api/upload returns
upload_result = {
'filename': 'screenshot.png',
'path': str(img),
'mime': 'image/png',
'size': img.stat().st_size,
'is_image': True,
}
# what the frontend sends to /api/chat/start
frontend_payload = [{
'name': upload_result['filename'],
'path': upload_result['path'],
'mime': upload_result['mime'],
'size': upload_result['size'],
'is_image': upload_result['is_image'],
}]
normalized = _normalize_chat_attachments(frontend_payload)
result = _build_native_multimodal_message('[WS]\n', 'describe this', normalized, str(root))
assert isinstance(result, list)
assert result[1]['type'] == 'image_url'
data_url = result[1]['image_url']['url']
assert data_url.startswith('data:image/png;base64,')
assert len(result) == 2
def test_fake_png_rejected_by_magic_bytes(self):
"""A file named .png that is not actually an image must be rejected."""
with TemporaryDirectory() as d:
root = Path(d)
fake = root / 'not-really.png'
fake.write_text('this is plain text, not an image')
atts = _normalize_chat_attachments([{
'name': 'not-really.png', 'path': str(fake),
'mime': 'image/png', 'size': fake.stat().st_size, 'is_image': True,
}])
result = _build_native_multimodal_message('', 'hi', atts, str(root))
assert isinstance(result, str)
# ── _is_valid_image magic-byte checks ────────────────────────────────────────
from api.streaming import _is_valid_image
class TestIsValidImage:
def test_valid_png(self):
with TemporaryDirectory() as d:
p = Path(d) / 'a.png'
_make_png(p)
assert _is_valid_image(p, 'image/png')
def test_valid_jpeg(self):
with TemporaryDirectory() as d:
p = Path(d) / 'a.jpg'
_make_jpeg(p)
assert _is_valid_image(p, 'image/jpeg')
def test_fake_png_rejected(self):
with TemporaryDirectory() as d:
p = Path(d) / 'fake.png'
p.write_text('hello world')
assert not _is_valid_image(p, 'image/png')
def test_text_file_not_image(self):
with TemporaryDirectory() as d:
p = Path(d) / 'notes.txt'
p.write_text('plain text')
assert not _is_valid_image(p, 'image/png')
assert not _is_valid_image(p, 'text/plain')
def test_svg_allowed(self):
"""SVG is text-based with no binary magic, so it passes."""
with TemporaryDirectory() as d:
p = Path(d) / 'diagram.svg'
p.write_text('<svg xmlns="http://www.w3.org/2000/svg"/>')
assert _is_valid_image(p, 'image/svg+xml')
def test_missing_file(self):
assert not _is_valid_image(Path('/no/such/file.png'), 'image/png')
def test_mime_with_charset(self):
with TemporaryDirectory() as d:
p = Path(d) / 'a.png'
_make_png(p)
assert _is_valid_image(p, 'image/png; charset=utf-8')
+55
View File
@@ -0,0 +1,55 @@
import os
from pathlib import Path
import yaml
def test_profile_runtime_env_includes_terminal_config_and_dotenv(tmp_path):
from api.profiles import get_profile_runtime_env
home = tmp_path / "profiles" / "server-ops"
home.mkdir(parents=True)
(home / "config.yaml").write_text(
yaml.safe_dump(
{
"terminal": {
"backend": "ssh",
"cwd": "/home/dso2ng/repos",
"timeout": 180,
"ssh_host": "pollux",
"ssh_user": "dso2ng",
"persistent_shell": True,
"lifetime_seconds": 300,
}
},
sort_keys=False,
),
encoding="utf-8",
)
(home / ".env").write_text(
"TERMINAL_TIMEOUT=60\n"
"TERMINAL_SSH_HOST=pollux-from-env\n"
"HERMES_MAX_ITERATIONS=90\n",
encoding="utf-8",
)
env = get_profile_runtime_env(home)
assert env["TERMINAL_ENV"] == "ssh"
assert env["TERMINAL_CWD"] == "/home/dso2ng/repos"
assert env["TERMINAL_SSH_USER"] == "dso2ng"
assert env["TERMINAL_PERSISTENT_SHELL"] == "true"
assert env["TERMINAL_LIFETIME_SECONDS"] == "300"
# .env remains the final override source, matching CLI/profile behaviour.
assert env["TERMINAL_TIMEOUT"] == "60"
assert env["TERMINAL_SSH_HOST"] == "pollux-from-env"
assert env["HERMES_MAX_ITERATIONS"] == "90"
def test_streaming_applies_profile_runtime_env_to_agent_run():
src = Path("api/streaming.py").read_text(encoding="utf-8")
assert "get_profile_runtime_env" in src
assert "_profile_runtime_env" in src
assert "old_profile_env" in src
assert "os.environ.update(_profile_runtime_env)" in src
+62 -4
View File
@@ -364,8 +364,15 @@ def test_legacy_at_provider_session_model_normalizes_when_provider_hidden(monkey
assert effective == "gpt-5.5"
def test_active_at_provider_session_model_strips_redundant_hint(monkeypatch):
"""@active-provider:model is an old persisted form; use the bare model now."""
def test_active_at_provider_session_model_preserved_with_hint(monkeypatch):
"""@active-provider:model must be preserved — stripping the prefix breaks duplicate-ID routing.
Before #1253 was fixed, this path stripped the @provider: prefix and returned
the bare model ID. That caused the picker to snap to the first matching provider
(not the explicitly selected one) on the next send, and the agent to run on the
wrong provider. The fix returns the full @provider:model unchanged so
resolve_model_provider() can route through the correct provider.
"""
import api.routes as routes
monkeypatch.setattr(
@@ -388,8 +395,10 @@ def test_active_at_provider_session_model_strips_redundant_hint(monkeypatch):
"@openai-codex:gpt-5.4-mini"
)
assert changed is True
assert effective == "gpt-5.4-mini"
# Must preserve the full @provider:model so resolve_model_provider() routes
# through openai-codex, not through whatever provider happens to be first.
assert changed is False
assert effective == "@openai-codex:gpt-5.4-mini"
def test_routable_non_active_at_provider_session_model_is_preserved(monkeypatch):
@@ -425,6 +434,55 @@ def test_routable_non_active_at_provider_session_model_is_preserved(monkeypatch)
assert effective == "@copilot:gpt-5.4"
def test_issue1253_duplicate_model_id_active_provider_hint_preserved(monkeypatch):
"""@provider:model where hint matches active provider must survive _resolve_compatible_session_model.
Regression test for #1253: when two providers both expose the same bare model ID
(e.g. both custom:edith and openai both expose 'gpt-5.4'), the picker stores the
selection as @custom:gpt-5.4. On chat/start that value must be returned unchanged
so resolve_model_provider() routes to 'custom', not to the default provider.
Before the fix, hint_matches_active=True caused the prefix to be stripped:
'@custom:gpt-5.4' ('gpt-5.4', True)
which then got written back to disk and sent as effective_model, snapping the
picker to the first (wrong) provider.
"""
import api.routes as routes
monkeypatch.setattr(
routes,
"get_available_models",
lambda: {
"active_provider": "custom",
"default_model": "gpt-5.4",
"groups": [
{
"provider": "Custom",
"provider_id": "custom",
"models": [{"id": "@custom:edith", "label": "Edith"}],
},
{
"provider": "OpenAI Codex",
"provider_id": "openai-codex",
"models": [{"id": "gpt-5.4", "label": "GPT-5.4"}],
},
],
},
)
# User selected the custom:edith model — explicit @provider:model form.
effective, changed = routes._resolve_compatible_session_model("@custom:edith")
# Must NOT be stripped to 'edith' — that would route to the default provider.
assert changed is False, (
f"_resolve_compatible_session_model must not strip @custom:edith "
f"(got effective='{effective}', changed={changed})"
)
assert effective == "@custom:edith", (
f"expected '@custom:edith', got '{effective}'"
)
def test_stale_at_provider_model_falls_back_when_family_mismatches(monkeypatch):
"""Unroutable @provider:model should not invent a bare model for another family."""
import api.routes as routes
+8 -4
View File
@@ -462,8 +462,10 @@ def test_streaming_restores_prior_reasoning_metadata_after_followup():
src = (REPO / 'api' / 'streaming.py').read_text()
assert "def _restore_reasoning_metadata(" in src, \
"streaming.py must define a helper to restore prior reasoning metadata"
assert "s.messages = _restore_reasoning_metadata(" in src, \
"streaming.py must merge prior reasoning metadata back after run_conversation()"
assert "s.context_messages = _next_context_messages" in src, \
"streaming.py must restore prior reasoning metadata into model context"
assert "s.messages = _merge_display_messages_after_agent_result(" in src, \
"streaming.py must merge restored result messages into the visible transcript"
assert "updated_messages.insert(safe_pos, copy.deepcopy(prev_msg))" in src, \
"streaming.py must reinsert dropped reasoning-only assistant messages"
@@ -473,8 +475,10 @@ def test_routes_restores_prior_reasoning_metadata_after_followup():
src = (REPO / 'api' / 'routes.py').read_text()
assert "_restore_reasoning_metadata" in src, \
"routes.py must import reasoning metadata restoration helper"
assert 's.messages = _restore_reasoning_metadata(' in src, \
"routes.py must merge prior reasoning metadata back after run_conversation()"
assert "s.context_messages = _next_context_messages" in src, \
"routes.py must restore prior reasoning metadata into model context"
assert 's.messages = _merge_display_messages_after_agent_result(' in src, \
"routes.py must merge restored result messages into the visible transcript"
class TestCredentialPoolBackwardCompat(unittest.TestCase):