Compare commits

...
Author SHA1 Message Date
StressTestor aac59573b8 fix(settings): name the gate-off option in its row copy
The Tool approval row copy opened with "Auto-approve turns off..." beside
an option now labelled Off (YOLO). Start the sentence with the option's
own name. Follow-up to #6304; no behaviour change.
2026-11-04 09:02:16 -07:00
Grzegorz c21980f5a6 fix(auth): revoke API tokens when an admin is demoted (#6831) (#6832) 2026-11-03 05:53:59 +00:00
Grzegorz 7fdc97880a fix(auth): reject usernames with slashes, control characters or over 64 characters (#6820) (#6821) 2026-11-03 05:50:21 +00:00
Grzegorz 9974841c49 fix(auth): validate privilege value types and reject non-object bodies (#6829) (#6830) 2026-11-03 05:48:20 +00:00
GrzegorzandClaude Opus 5.5 631c05603c fix(cookbook): make the Dependencies recipe Run button pass validation (#6725) (#6726)
The recipe Run button sent repo_id "<backend> setup"; the space fails the
pip package-spec check, so every Run returned "Invalid pip package name".
It now sends "<backend>-setup".

The vLLM/SGLang recipes start with `uv pip install ... --torch-backend
auto`, but uv was not in the serve command allowlist. Allow uv only for
the `pip install` subcommand.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:41:21 +00:00
red person f814018098 fix(documents): preserve read pagination within tool output budget (#6810) 2026-11-03 05:37:48 +00:00
Grzegorz d47d85d8a4 fix(history): only accept user/assistant string messages on the session message route (#6826) (#6827) 2026-11-03 05:35:25 +00:00
John WandClaude Opus 5.5 77964b0025 fix(ui): keep the confirm dialog's OK button in line with the others (#6782)
* fix(ui): keep the confirm dialog's OK button in line with the others

#styled-confirm-ok was moved down 2px (position: relative; top: 2px), so
the OK button sat 2px below Cancel and the alternate button in every
styledConfirm dialog. Its label, and the alternate button's, now sit in a
span nudged the same way as the Cancel label, so the text keeps its place
and no button moves. Stylesheets and app.js get a new cache-bust token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui): build confirm labels inline and share the Cancel nudge rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cache): keep upstream's service worker cache name format

dev's editor tests require CACHE_NAME to match
odysseus-v<N>-pr40-editor-precache, so keep the version bump and use
that suffix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:29:42 +00:00
red person 682662d36f fix(email): decode MIME alternatives without duplicating bodies (#6953) 2026-11-03 00:39:55 +00:00
red person b7ab0a37c1 fix(modals): keep restored settings and theme windows open (#6924)
* fix(modals): keep restored settings and theme windows open

* fix(theme): cancel closing on a rapid toggle reopen

* test: isolate photo routing from external MCP registry state

* fix(cache): preserve the upstream editor worker key format
2026-11-03 00:37:38 +00:00
red person 3efbecd50a fix(skills): restore the source editor from test results (#6931)
* fix(skills): restore the source editor from test results

* fix(skills): align the chat link module version

* test: isolate photo routing from external MCP registry state
2026-11-03 00:35:37 +00:00
red person 06c1e71fa0 fix(research): typeset math in visual reports with the shared renderer (#6929) 2026-11-03 00:33:43 +00:00
red person 1e34f938cc fix(chat): export the complete conversation across PDF pages (#6921) 2026-11-03 00:30:07 +00:00
John WandClaude Opus 5.5 596e1421e4 fix(ui): let confirm-dialog buttons grow to fit wrapped labels (#6617)
* fix(ui): let confirm-dialog buttons grow to fit wrapped labels

The global `button { height:32px }` rule also applies to styledConfirm's
buttons. A label that wraps to two lines, such as "Delete chat and images",
"Launch anyway (CPU)" or a three-button dialog's alternate action, keeps
one line's height, so its second line renders below the button. On narrow
windows the dialog makes the buttons equal width, so this hits most
multi-word labels; it also happens on desktop, where the dialog box is
narrow too.

Give .styled-confirm-box .confirm-btn height:auto with min-height:32px.
Single-line buttons keep their current 32px.

Bumps the shared stylesheet/app cache token and the service worker cache
name, since a stylesheet changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(static): leave admin.js's cache tag unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cache): keep upstream's service worker cache name format

dev's editor tests require CACHE_NAME to match
odysseus-v<N>-pr40-editor-precache, so keep the version bump and use
that suffix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:27:29 +00:00
red person cad14f81b9 fix(documents): preserve the selected chat mode on open (#6945)
* fix(documents): preserve the selected chat mode on open

* test: isolate photo routing from external MCP registry state
2026-11-03 00:25:29 +00:00
red person 8532f4606b fix(models): resolve configured defaults after automatic fallback (#6949)
* fix(models): resolve configured defaults after automatic fallback

* test: isolate photo routing from external MCP registry state
2026-11-03 00:23:17 +00:00
red person 5d4e8d57bb fix(memory): rank identity records with other relevance matches (#6925) 2026-11-03 00:21:16 +00:00
red person ed531cfc61 fix(hwfit): complete CPU probe results and clear remote ports (#6920) 2026-11-03 00:19:03 +00:00
Merajul Arefin f00ae350cd fix(cli): multi-word and literal-wildcard search in odysseus-docs (#7007)
* fix(cli): multi-word and literal-wildcard search in odysseus-docs

odysseus-docs search wrapped the whole query in one `%{query}%` LIKE over title/current_content, so multi-word queries only matched the exact adjacent phrase and literal % / _ acted as SQL wildcards. Split on whitespace, AND per token, and escape LIKE wildcards with the shared escape_like_pattern() helper. Whitespace-only queries now match all documents, consistent with document-library search.

Fixes #2123.

Tests: tests/cli/test_docs_cli_search.py (5 tests; multi-word, %, _, backslash, whitespace); 4 fail pre-fix, 5 pass post-fix. tests/cli full suite: 82 passed.

* test(cli): name whitespace-query search test accurately

The test passes a whitespace-only query, not an empty one; the old name implied a different input. Behavior is unchanged.
2026-11-02 23:48:14 +00:00
red person c1f959ae61 fix(research): retain reports after failed deletion (#7043) 2026-11-02 23:45:48 +00:00
51 changed files with 1937 additions and 230 deletions
+39 -1
View File
@@ -47,7 +47,7 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
# backwards for this sentinel.
ADMIN_PRIVILEGES["block_all_models"] = False
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH, USERNAME_MAX_LENGTH
from src.owner_identity import RESERVED_AUTH_USERNAMES
DEFAULT_AUTH_PATH = AUTH_FILE
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
@@ -62,6 +62,24 @@ TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
def username_error(username: str | None) -> Optional[str]:
"""Why a new username is not acceptable, or None when it is.
Admin routes carry the name as a URL path segment, where "/" never
reaches the handler, so such an account could not be managed afterwards.
"""
name = (username or "").strip()
if not name:
return "Username is required"
if len(name) > USERNAME_MAX_LENGTH:
return f"Username must be at most {USERNAME_MAX_LENGTH} characters"
if "/" in name or "\\" in name or any(not c.isprintable() for c in name):
return "Username must not contain slashes or control characters"
if name in (".", ".."):
return "Username is not allowed"
return None
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
"""Return a normalized username only when it exists in the auth user map."""
key = str(username or "").strip().lower()
@@ -396,6 +414,15 @@ class AuthManager:
current = self.get_privileges(username)
for k, v in privileges.items():
if k in DEFAULT_PRIVILEGES:
default = DEFAULT_PRIVILEGES[k]
if isinstance(default, bool):
ok = isinstance(v, bool)
elif isinstance(default, int):
ok = isinstance(v, int) and not isinstance(v, bool)
else:
ok = isinstance(v, list)
if not ok:
raise ValueError(f"Invalid value for privilege '{k}'")
current[k] = v
self._config["users"][username]["privileges"] = current
self._save()
@@ -436,6 +463,17 @@ class AuthManager:
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
if admin_count <= 1:
return SetAdminResult.LAST_ADMIN
# Token creation is admin-only and a demoted user can neither
# list nor revoke their tokens, so purge them with the role.
# Done before the flag flips: if the token store fails the
# exception propagates and the user is still an admin.
from core.database import get_db_session, ApiToken
with get_db_session() as db:
removed_tokens = db.query(ApiToken).filter(ApiToken.owner == username).delete()
if removed_tokens:
logger.info(
f"Revoked {removed_tokens} API token(s) owned by demoted admin '{username}'"
)
# Write order matters for lock-free readers: get_privileges()
# reads without _config_lock and trusts is_admin, so the admin
# flag must be flipped while the stored map is safe to expose —
+12 -15
View File
@@ -33,6 +33,7 @@ from mcp.types import Tool, TextContent
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.email_mime import decode_text_part as _decode_text_part
server = Server("email")
EMAIL_SOCKET_TIMEOUT = float(os.environ.get("EMAIL_SOCKET_TIMEOUT", "20"))
from src.constants import DATA_DIR as _DATA_DIR, APP_DB, EMAIL_CACHE_DB, SETTINGS_FILE as _SETTINGS_FILE, MAIL_ATTACHMENTS_DIR
@@ -923,29 +924,25 @@ def _extract_text(msg):
"""Extract plain text body from email message."""
if msg.is_multipart():
text_parts = []
html_parts = []
for part in msg.walk():
ct = part.get_content_type()
cd = str(part.get("Content-Disposition", ""))
if ct == "text/plain" and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
text_parts.append(payload.decode(charset, errors="replace"))
elif ct == "text/html" and not text_parts and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
raw_html = payload.decode(charset, errors="replace")
text = _decode_text_part(part)
if text:
text_parts.append(text)
elif ct == "text/html" and not html_parts and "attachment" not in cd:
raw_html = _decode_text_part(part)
if raw_html:
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
text_parts.append(text.strip())
return "\n".join(text_parts)
html_parts.append(text.strip())
return "\n".join(text_parts or html_parts)
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
text = payload.decode(charset, errors="replace")
text = _decode_text_part(msg)
if text:
if msg.get_content_type() == "text/html":
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
+35 -10
View File
@@ -13,7 +13,7 @@ import re
from pathlib import Path
from core.atomic_io import atomic_write_json, atomic_write_text
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL, username_error
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
from src.auth_helpers import _auth_disabled
from src.rate_limiter import RateLimiter
@@ -155,8 +155,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(400, "Already configured")
if len(body.password) < PASSWORD_MIN_LENGTH:
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
if len(body.username.strip()) < 1:
raise HTTPException(400, "Username is required")
name_error = username_error(body.username)
if name_error:
raise HTTPException(400, name_error)
if body.username.lower() in RESERVED_USERNAMES:
raise HTTPException(403, "Username is reserved")
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
@@ -175,8 +176,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
if len(body.password) < PASSWORD_MIN_LENGTH:
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
if len(body.username.strip()) < 1:
raise HTTPException(400, "Username is required")
name_error = username_error(body.username)
if name_error:
raise HTTPException(400, name_error)
if body.username.lower() in RESERVED_USERNAMES:
raise HTTPException(403, "Username is reserved")
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
@@ -334,8 +336,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(403, "Admin only")
if len(body.password) < PASSWORD_MIN_LENGTH:
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
if len(body.username.strip()) < 1:
raise HTTPException(400, "Username is required")
name_error = username_error(body.username)
if name_error:
raise HTTPException(400, name_error)
if body.username.lower() in RESERVED_USERNAMES:
raise HTTPException(403, "Username is reserved")
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
@@ -362,8 +365,16 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
body = await request.json()
ok = auth_manager.set_privileges(username, body)
try:
body = await request.json()
except ValueError:
raise HTTPException(400, "Request body must be a JSON object")
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
try:
ok = auth_manager.set_privileges(username, body)
except ValueError as e:
raise HTTPException(400, str(e))
if not ok:
raise HTTPException(404, "User not found or is admin")
return {"ok": True, "privileges": auth_manager.get_privileges(username)}
@@ -377,6 +388,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
new_username = (body.username or "").strip().lower()
if not new_username:
raise HTTPException(400, "Username required")
name_error = username_error(new_username)
if name_error:
raise HTTPException(400, name_error)
if old_username == new_username:
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
if old_username not in auth_manager.users:
@@ -654,7 +668,18 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
result = auth_manager.set_admin(username, body.is_admin, user)
try:
result = auth_manager.set_admin(username, body.is_admin, user)
finally:
# A demotion purges the user's ApiToken rows; dirty the bearer
# cache so already-cached tokens stop authenticating.
if not body.is_admin:
invalidator = getattr(
getattr(getattr(request, "app", None), "state", None),
"invalidate_token_cache", None,
)
if callable(invalidator):
invalidator()
if result is SetAdminResult.USER_NOT_FOUND:
raise HTTPException(404, "User not found")
if result is SetAdminResult.NOT_AUTHORIZED:
+6 -1
View File
@@ -728,8 +728,13 @@ def _check_serve_binary(seg: str) -> None:
if not tokens:
return
env_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
first = next((t for t in tokens if not env_re.match(t)), "")
idx = next((i for i, t in enumerate(tokens) if not env_re.match(t)), len(tokens))
first = tokens[idx] if idx < len(tokens) else ""
base = os.path.basename(first)
# The vLLM/SGLang Dependencies recipes install with `uv pip install …
# --torch-backend auto`. Allow uv for that subcommand only, not `uv run`.
if base == "uv" and tokens[idx + 1:idx + 3] == ["pip", "install"]:
return
if base not in _SERVE_CMD_ALLOWLIST:
raise HTTPException(
400,
+18 -23
View File
@@ -36,6 +36,7 @@ from fastapi import Query, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, List
from src.email_mime import decode_text_part as _decode_text_part
from src.auth_helpers import _auth_disabled, get_current_user
from src.secret_storage import decrypt as _decrypt
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
@@ -1776,44 +1777,38 @@ def _extract_html(msg):
ct = part.get_content_type()
cd = str(part.get("Content-Disposition", ""))
if ct == "text/html" and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
text = _decode_text_part(part)
if text:
return text
elif msg.get_content_type() == "text/html":
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
text = _decode_text_part(msg)
if text:
return text
return None
def _extract_text(msg):
if msg.is_multipart():
text_parts = []
html_parts = []
for part in msg.walk():
ct = part.get_content_type()
cd = str(part.get("Content-Disposition", ""))
if ct == "text/plain" and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
text_parts.append(payload.decode(charset, errors="replace"))
elif ct == "text/html" and not text_parts and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
raw_html = payload.decode(charset, errors="replace")
text = _decode_text_part(part)
if text:
text_parts.append(text)
elif ct == "text/html" and not html_parts and "attachment" not in cd:
raw_html = _decode_text_part(part)
if raw_html:
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
text_parts.append(text.strip())
return "\n".join(text_parts)
html_parts.append(text.strip())
return "\n".join(text_parts or html_parts)
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
text = payload.decode(charset, errors="replace")
text = _decode_text_part(msg)
if text:
if msg.get_content_type() == "text/html":
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
+6
View File
@@ -471,10 +471,16 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
_verify_session_owner(request, session_id)
try:
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
role = body.get("role", "assistant")
content = body.get("content", "")
if role not in ("user", "assistant"):
raise HTTPException(400, "role must be 'user' or 'assistant'")
if not content:
raise HTTPException(400, "content is required")
if not isinstance(content, str):
raise HTTPException(400, "content must be a string")
metadata = sanitize_client_message_metadata(body.get("metadata"))
_reserve_message_uploads(request, content, metadata)
msg = ChatMessage(role=role, content=content, metadata=metadata)
+12 -4
View File
@@ -27,6 +27,7 @@ from pathlib import Path
try:
from core.database import SessionLocal, Document, DocumentVersion
from src.session_search import escape_like_pattern
quiet_logs()
except ModuleNotFoundError as e:
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
@@ -139,10 +140,17 @@ def cmd_export(args):
def cmd_search(args):
db = SessionLocal()
try:
like = f"%{args.query}%"
rows = db.query(Document).filter(
(Document.title.ilike(like)) | (Document.current_content.ilike(like))
).order_by(Document.updated_at.desc()).limit(args.limit).all()
# Per-term AND search: a single `%foo bar%` LIKE only matched the
# exact phrase, so "machine learning" missed docs containing both
# words apart. LIKE wildcards are escaped per token.
q = db.query(Document)
for tok in args.query.split():
term = f"%{escape_like_pattern(tok)}%"
q = q.filter(
Document.title.ilike(term, escape="\\")
| Document.current_content.ilike(term, escape="\\")
)
rows = q.order_by(Document.updated_at.desc()).limit(args.limit).all()
emit([_serialize(d) for d in rows], args)
finally:
db.close()
+9
View File
@@ -660,6 +660,8 @@ def _detect_windows():
"gpu_vram_gb": d.get("gpu_vram_gb"),
"gpu_count": _as_int(d.get("gpu_count"), 0),
"backend": d.get("gpu_backend", "cpu_x86"),
"gpus": [],
"gpu_groups": [],
"homogeneous": True,
"gpu_error": None,
"platform": "windows",
@@ -822,12 +824,14 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
if result:
result = _attach_probe_context(result, host=host)
_remote_host = None
_remote_port = None
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
return result
# SSH may work while the PowerShell hardware probe still fails.
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
_remote_host = None
_remote_port = None
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
return result
@@ -852,6 +856,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
result = {"error": f"Cannot connect to {host}", "host": host}
_cache_by_host[cache_key] = (now, result)
_remote_host = None
_remote_port = None
_remote_platform = None
return result
available_ram = round(_get_available_ram_gb(), 1)
@@ -893,6 +898,9 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
"gpu_name": None,
"gpu_vram_gb": None,
"gpu_count": 0,
"gpus": [],
"gpu_groups": [],
"homogeneous": True,
"backend": backend,
# Set when nvidia-smi exists but failed (e.g. driver/library
# version mismatch) — lets the UI say "GPU driver error" instead
@@ -902,6 +910,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
result = _attach_probe_context(result, host=host)
_remote_host = None
_remote_port = None
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
return result
+12 -3
View File
@@ -4,7 +4,7 @@ import html
import difflib
import logging
import re
from src.constants import MAX_READ_CHARS
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS
from src.tool_approvals import document_content_digest
from src.tool_utils import _parse_tool_args, get_upload_handler
from src.upload_handler import reserve_upload_references
@@ -1175,14 +1175,23 @@ class ManageDocumentTool:
except (TypeError, ValueError):
offset = 0
offset = min(offset, len(body))
anchor = f"[{doc.title}](#document-{doc.id})"
header = f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n"
# Reserve the closing fence and the longest possible paging
# notice before selecting the page. The common output cap must
# never hide content that next_offset claims was delivered.
notice = f"\n... (truncated, {len(body)} chars total; next_offset={len(body)})"
page_budget = MAX_OUTPUT_CHARS - len(header) - len(notice) - len("\n```")
if page_budget < 1:
return {"error": "Document title or language is too long to display a read page", "exit_code": 1}
preview_limit = min(preview_limit, page_budget)
end = min(offset + preview_limit, len(body))
truncated = end < len(body)
preview = body[offset:end]
if truncated:
preview += f"\n... (truncated, {len(body)} chars total; next_offset={end})"
anchor = f"[{doc.title}](#document-{doc.id})"
return {
"response": f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n{preview}\n```",
"response": f"{header}{preview}\n```",
"document": {
"id": doc.id,
"title": doc.title,
+1
View File
@@ -175,6 +175,7 @@ AGENT_ISOLATED_TMP_DIRNAME = ".tmp"
# Auth policy
PASSWORD_MIN_LENGTH = 8
USERNAME_MAX_LENGTH = 64
# Default parameters
DEFAULT_TEMPERATURE = 1.0
+13
View File
@@ -0,0 +1,13 @@
"""Shared MIME text decoding for the web email helpers and MCP server."""
def decode_text_part(part) -> str:
payload = part.get_payload(decode=True)
if not payload:
return ""
charset = part.get_content_charset() or "utf-8"
try:
return payload.decode(charset, errors="replace")
except (LookupError, ValueError):
# Broken senders can label valid UTF-8 with an unsupported codec.
return payload.decode("utf-8", errors="replace")
+15 -26
View File
@@ -364,32 +364,32 @@ class MemoryManager:
return []
# Define keyword categories for semantic matching
identity_words = ["name", "who", "i", "am", "called", "identity", "myself", "me", "my"]
identity_words = ["name", "called", "identity", "myself"]
contact_words = ["phone", "email", "address", "contact", "number", "where", "located", "reach"]
preference_words = ["like", "prefer", "favorite", "want", "love", "hate", "dislike", "enjoy", "interested"]
task_words = ["todo", "task", "remind", "meeting", "appointment", "schedule", "deadline"]
fact_words = ["what", "when", "where", "how", "why", "explain", "describe", "information", "know"]
query_lower = query.lower()
query_words = set(re.findall(r'\w+', query_lower))
query_tokens = set(tokenize(query_lower))
# Determine query type based on keywords
query_type = None
if any(word in query_lower for word in identity_words):
if query_words.intersection(identity_words) or re.search(r'\bwho\s+am\s+i\b', query_lower):
query_type = "identity"
elif any(word in query_lower for word in contact_words):
elif query_words.intersection(contact_words):
query_type = "contact"
elif any(word in query_lower for word in preference_words):
elif query_words.intersection(preference_words):
query_type = "preference"
elif any(word in query_lower for word in task_words):
elif query_words.intersection(task_words):
query_type = "task"
elif any(word in query_lower for word in fact_words):
elif query_words.intersection(fact_words):
query_type = "fact"
relevant = []
identity_memories = []
other_memories = []
# Separate identity memories from others
# Every memory participates in the same ranking and threshold check.
# Query type is a scoring hint, not an unconditional insertion path.
for memory in memories:
memory_text = memory["text"].lower()
# Check if this is an identity memory (contains name patterns or identity indicators)
@@ -397,22 +397,7 @@ class MemoryManager:
re.search(r'\b[A-Z][a-z]+ [A-Z][a-z]+\b', memory["text"]),
any(word in memory_text for word in ["name is", "i'm", "i am", "called", "my name", "named", "call me"])
])
if is_identity:
identity_memories.append(memory)
else:
other_memories.append(memory)
# For identity queries, include all identity memories regardless of similarity
if query_type == "identity" and identity_memories:
# Give them high scores to ensure they're included first
for memory in identity_memories:
relevant.append((0.9, memory)) # High score for identity memories in identity queries
# Process other memories with similarity scoring
for memory in other_memories:
memory_text = memory["text"].lower()
memory_tokens = set(tokenize(memory_text))
query_tokens = set(tokenize(query_lower))
# Calculate base Jaccard similarity
if not query_tokens or not memory_tokens:
@@ -422,7 +407,11 @@ class MemoryManager:
final_score = base_similarity
# Apply boosts based on semantic matching
if query_type == "contact":
if query_type == "identity" and is_identity:
# Keep explicit name/identity recall useful without outranking
# a closer textual match or bypassing the caller's threshold.
final_score = max(final_score * 1.3, 0.6)
elif query_type == "contact":
# Boost memories with contact information
has_contact_info = any(word in memory_text for word in ["@gmail.com", "@", ".com",
"phone", "number", "address",
+87
View File
@@ -0,0 +1,87 @@
"""Preserve report TeX for markdown.js's existing deferred KaTeX renderer."""
import re
from html.parser import HTMLParser
from xml.etree import ElementTree
from markdown.blockprocessors import BlockProcessor
from markdown.extensions import Extension
from markdown.inlinepatterns import InlineProcessor
from markdown.util import AtomicString
# Keep delimiter and currency rules aligned with mathBlocks in markdown.js.
_ESCAPED_MATH = r"\\\[([\s\S]*?)\\\]|\\\(([^\n]*?)\\\)"
_DOLLAR_MATH = (
r"\$\$([\s\S]*?)\$\$|"
r"(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)"
)
def _placeholder(tex, display):
node = ElementTree.Element("span", {
"class": "ody-math-pending", "data-display": str(display).lower(),
})
node.text = AtomicString(tex)
return node
class _HtmlContext(HTMLParser):
"""Inline HTML is still raw when math runs, including tag attributes."""
def __init__(self):
super().__init__()
self.protected = []
def handle_starttag(self, tag, attrs):
if tag in {"code", "pre", "script", "style", "textarea"}:
self.protected.append(tag)
def handle_endtag(self, tag):
if tag in self.protected:
del self.protected[self.protected.index(tag):]
class _InlineMath(InlineProcessor):
def handleMatch(self, match, data):
prefix = data[:match.start()]
if "<" in prefix:
context = _HtmlContext()
context.feed(prefix)
if context.protected or re.match(r"<(?:/?[A-Za-z]|[!?])", context.rawdata.lstrip()):
return None, None, None
# Backticks run before this processor. Preserve a backslash-escaped
# dollar too; math must run before Markdown consumes \( and \[.
if prefix.endswith("\\") and (len(prefix) - len(prefix.rstrip("\\"))) % 2:
return None, None, None
tex = next(group for group in match.groups() if group is not None)
display = match.group(1) is not None
return _placeholder(tex, display), match.start(), match.end()
class _DisplayMath(BlockProcessor):
def test(self, parent, block):
return re.match(r"^ {0,3}(\$\$|\\\[)", block) is not None
def run(self, parent, blocks):
opening = re.match(r"^ {0,3}(\$\$|\\\[)", blocks[0])
closing = "$$" if opening.group(1) == "$$" else "\\]"
source = blocks[0][opening.end():]
consumed = 1
while closing not in source:
if consumed == len(blocks):
return False # Leave incomplete input to ordinary Markdown.
source += "\n\n" + blocks[consumed]
consumed += 1
tex, _, tail = source.partition(closing)
del blocks[:consumed]
if tail.strip():
blocks.insert(0, tail)
parent.append(_placeholder(tex, True))
class ReportMathExtension(Extension):
def extendMarkdown(self, md):
# Backticks (190) and indented code (80) retain precedence.
md.inlinePatterns.register(_InlineMath(_ESCAPED_MATH, md), "report_math_escaped", 185)
# Parse links before dollar math so URL destinations stay literal.
md.inlinePatterns.register(_InlineMath(_DOLLAR_MATH, md), "report_math_dollar", 155)
md.parser.blockprocessors.register(_DisplayMath(md.parser), "report_math", 79)
+10
View File
@@ -2562,6 +2562,16 @@ def _compact_binary_like_output(value: object) -> str:
def format_tool_result(description: str, result: Dict) -> str:
"""Format a tool result into text for feeding back to the LLM."""
if (
description.startswith("manage_documents:")
and isinstance(result.get("document"), dict)
and "response" in result
):
# Document reads already budget their complete page, anchor and
# next_offset notice. A heading or a second JSON copy of the content
# would exhaust that budget and obscure the continuation again.
return _truncate(result["response"])
parts = [f"### {description}"]
if "stdout" in result:
+28 -3
View File
@@ -23,6 +23,7 @@ from typing import Dict, List, Optional, Tuple
from bs4 import BeautifulSoup
from src.research_utils import strip_thinking
from src.report_math import ReportMathExtension
from urllib.parse import urlparse
import markdown
@@ -47,6 +48,7 @@ for _t in ("td", "th"):
_REPORT_ALLOWED_ATTRS.setdefault(_t, set()).add("align")
_REPORT_ALLOWED_ATTRS.setdefault("a", set()).update({"href", "title", "target", "rel"})
_REPORT_ALLOWED_ATTRS.setdefault("img", set()).update({"src", "alt", "title"})
_REPORT_ALLOWED_ATTRS.setdefault("span", set()).add("data-display")
# ---------------------------------------------------------------------------
# Helpers
@@ -79,7 +81,7 @@ def _md_to_html(md_text: str) -> str:
md_text = _autolink_urls(md_text)
result = markdown.markdown(
md_text,
extensions=["extra", "codehilite", "toc", "tables", "sane_lists", "md_in_html"],
extensions=["extra", "codehilite", "toc", "tables", "sane_lists", "md_in_html", ReportMathExtension()],
extension_configs={
"codehilite": {"css_class": "code", "guess_lang": False},
"toc": {"marker": "", "toc_depth": "2-3"},
@@ -1021,6 +1023,8 @@ body[class^="standard-report-"] .report-section:nth-of-type(even) {{
.content code {{ font-family: var(--font-mono); font-size: 0.86em; background: var(--bg-surface-alt); padding: 0.15em 0.4em; border-radius: 4px; }}
.content pre {{ background: var(--bg-surface-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem 1.5rem; overflow-x: auto; margin: 1.25rem 0; font-size: 0.86rem; line-height: 1.6; }}
.content pre code {{ background: none; padding: 0; }}
/* Match the chat renderer's handling of wide display equations. */
.content .katex-display {{ overflow-x: auto; overflow-y: hidden; }}
.content table {{ width: 100%; border-collapse: collapse; margin: 1.25rem 0; font-size: 0.9rem; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm); }}
.content th {{ text-align: left; padding: 0.7rem 1rem; background: var(--accent-bg); font-weight: 600; border-bottom: 2px solid var(--border-strong); }}
.content td {{ padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); vertical-align: top; }}
@@ -1434,6 +1438,12 @@ body[class^="standard-report-"] .report-section:nth-of-type(even) {{
<script>
(function() {{
// Reuse the chat renderer and vendored assets only for reports with math.
if (document.querySelector('.ody-math-pending')) {{
import('/static/js/markdown.js').then(function(markdown) {{
return markdown.renderMath(document);
}}).catch(function(err) {{ console.warn('Report math render error:', err); }});
}}
// ESC closes the report tab. window.close() works when the tab was
// opened via window.open() (which is how the panel launches it). If the
// browser blocks self-close (rare — e.g. report opened by direct URL),
@@ -2835,11 +2845,23 @@ def generate_visual_report(
spare_images = section_pool[_consumed:]
# Build TOC
math_labels = {}
if 'ody-math-pending' in report_html:
label_soup = BeautifulSoup(report_html, "html.parser")
for heading in label_soup.select('h2[id], h3[id]'):
if not heading.select_one('.ody-math-pending'):
continue
for child in list(heading.find_all(True)):
if 'ody-math-pending' in child.get('class', []):
child['data-display'] = 'false'
else:
child.unwrap()
math_labels[heading['id']] = heading.decode_contents()
toc_lines = []
for h in headings:
depth_class = f"depth-{h['level']}"
toc_lines.append(
f'<a href="#{h["slug"]}" class="{depth_class}">{html.escape(h["text"])}</a>'
f'<a href="#{h["slug"]}" class="{depth_class}">{math_labels.get(h["slug"], html.escape(h["text"]))}</a>'
)
toc_html = "\n ".join(toc_lines) if toc_lines else ""
@@ -2848,8 +2870,11 @@ def generate_visual_report(
options = []
for h in headings:
prefix = "\u2014 " if h["level"] == 3 else ""
text = h["text"]
if h["slug"] in math_labels:
text = BeautifulSoup(math_labels[h["slug"]], "html.parser").get_text()
options.append(
f'<option value="{h["slug"]}">{prefix}{html.escape(h["text"])}</option>'
f'<option value="{h["slug"]}">{prefix}{html.escape(text)}</option>'
)
mobile_toc_html = (
'<div class="mobile-section-nav">'
+13 -3
View File
@@ -561,10 +561,21 @@ function initializeEventListeners() {
// Export: PDF
const exportPdfBtn = el('export-pdf-btn');
let preparingPdf = false;
if (exportPdfBtn) {
exportPdfBtn.addEventListener('click', (e) => {
exportPdfBtn.addEventListener('click', async (e) => {
e.stopPropagation();
exportMenu.classList.remove('open');
if (preparingPdf) return;
preparingPdf = true;
try {
await sessionModule.prepareHistoryForPrint();
} catch (error) {
uiModule.showError(error?.message || 'Could not prepare the chat for PDF export.');
return;
} finally {
preparingPdf = false;
}
const meta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
const sessionName = meta ? meta.name : 'Odysseus Chat';
const originalTitle = document.title;
@@ -1308,8 +1319,7 @@ function initializeEventListeners() {
const toolThemeBtn = el('tool-theme-btn');
if (toolThemeBtn) {
toolThemeBtn.addEventListener('click', () => {
const tm = document.getElementById('theme-modal');
if (tm) tm.classList.remove('hidden');
themeModule.openPopup();
});
}
+9 -4
View File
@@ -6481,6 +6481,13 @@ body.bg-pattern-ascii-fireflies {
display:flex; justify-content:flex-end; gap:8px; padding-top:6px;
border-top:1px solid var(--border); margin-top:4px;
}
/* The global `button { height:32px }` would hold a wrapped label (e.g.
"Delete chat and images") to one line's height, so its second line
spills out below the button. Let confirm buttons grow to fit; 32px
stays the minimum, so single-line buttons look the same. */
.styled-confirm-box .confirm-btn {
height:auto; min-height:32px;
}
@media (max-width:768px) {
.styled-confirm-box {
width: 85vw;
@@ -6516,10 +6523,8 @@ body.bg-pattern-ascii-fireflies {
}
.confirm-btn-secondary { background:var(--bg); color:var(--fg); }
.confirm-btn-secondary:hover { background:var(--border); }
#styled-confirm-ok {
position: relative;
top: 2px;
}
#styled-confirm-ok .styled-confirm-btn-label,
#styled-confirm-alt .styled-confirm-btn-label,
#styled-confirm-cancel .styled-confirm-cancel-label {
position:relative;
top:2px;
+1
View File
@@ -1701,6 +1701,7 @@
/* ── Print / PDF Export ── */
@media print {
html, body { height: auto !important; overflow: visible !important; }
body { background: #fff !important; color: #000 !important; }
#sidebar, .sidebar, #icon-rail, .hamburger-btn, #sidebar-backdrop, .chat-input-bar, .input-bar-wrapper,
#welcome-screen, .chat-top-bar, .chat-meta-overlay, .msg-footer,
+24 -24
View File
@@ -247,32 +247,32 @@
real request is discarded and the font fetched a second time. -->
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/02-compare.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/04-memory.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/05-documents.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/07-documents.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/08-skills.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/14-email.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/15-notes.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/17-research.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/02-compare.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/04-memory.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/05-documents.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/07-documents.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/08-skills.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/14-email.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/15-notes.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/17-research.css?v=20261021confirmalign1">
<!-- Panel stylesheets split out of style.css for maintainability. They are
eager and their order is load-bearing: the cascade is the concatenation of
these files in this order, so they must stay in this order here and in the
PRECACHE list in sw.js. None of them is lazy-loaded. -->
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20261007detailscroll1">
<link rel="modulepreload" href="/static/app.js?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20261021confirmalign1">
<link rel="modulepreload" href="/static/app.js?v=20261021confirmalign1">
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
<link rel="modulepreload" href="/static/js/sessions.js">
@@ -2472,7 +2472,7 @@
</div>
<div class="admin-toggle-sub" style="margin-top:4px;">
Auto-approve turns off the tool approval gate (same control as the
Off (YOLO) turns off the tool approval gate (same control as the
ODYSSEUS_TOOL_APPROVAL_GATE env var). Bash, host_shell, send_email and
delete_email then run without a confirmation even after the agent has
seen untrusted external content (email, web pages, documents), so a
@@ -2709,7 +2709,7 @@
<script type="module" src="/static/js/censor.js"></script>
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
<script type="module" src="/static/app.js?v=20261007detailscroll1"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/app.js?v=20261021confirmalign1"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
<script type="module" src="/static/js/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
+4 -2
View File
@@ -1762,7 +1762,9 @@ async function _fetchDependencies() {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
}
const reqBody = {
repo_id: `${backend} setup`,
// The server validates repo_id as a pip package spec for pip
// installs, so it can't contain a space.
repo_id: `${backend}-setup`,
cmd: cmd,
remote_host: _envState.remoteHost || undefined,
ssh_port: _getPort(_envState.remoteHost) || undefined,
@@ -1780,7 +1782,7 @@ async function _fetchDependencies() {
uiModule.showToast('Run failed: ' + String(data.detail || data.error || `HTTP ${res.status}`).slice(0, 200));
return;
}
const payload = { repo_id: `${backend} setup`, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
const payload = { repo_id: reqBody.repo_id, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
_addTask(data.session_id, `${backend} setup`, 'download', payload);
uiModule.showToast(`Running ${backend} setup on ${targetHost}…`);
} catch (err) {
-10
View File
@@ -1062,15 +1062,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
}
/** Switch chat to agent mode if not already */
function _ensureAgentMode() {
const ab = document.getElementById('mode-agent-btn');
const cb = document.getElementById('mode-chat-btn');
if (ab && !ab.classList.contains('active')) {
ab.click();
}
}
export function init(apiBase) {
API_BASE = apiBase;
initLibrary({
@@ -7415,7 +7406,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// Doc was opened last → it goes in front of the email windows (clears the
// email-front flag; the doc/email z-index alternation lives in CSS).
document.body.classList.remove('email-front');
_ensureAgentMode();
_markDocVisibleState(_lastSessionId, 'open');
document.body.classList.add('doc-view');
+42
View File
@@ -0,0 +1,42 @@
// Own both completion paths of an animated close so neither can outlive it.
const _pendingCloses = new WeakMap();
function cancelClose(modal) {
const pending = _pendingCloses.get(modal);
if (!pending) return;
clearTimeout(pending.timer);
pending.content.removeEventListener('animationend', pending.onEnd);
pending.content.classList.remove('modal-closing');
_pendingCloses.delete(modal);
}
export function showModal(modal) {
if (!modal) return;
cancelClose(modal);
modal.classList.remove('hidden');
}
export function hideModal(modal) {
if (!modal) return;
cancelClose(modal);
const content = modal.querySelector('.modal-content, .settings-modal-content');
// A minimized window has no exit animation. Registering a listener here
// would let its next entrance animation close the newly opened window.
if (!content || modal.classList.contains('hidden')) {
modal.classList.add('hidden');
return;
}
const pending = { content, timer: null, onEnd: null };
const finish = () => {
if (_pendingCloses.get(modal) !== pending) return;
cancelClose(modal);
modal.classList.add('hidden');
};
pending.onEnd = event => {
if (event.target === content) finish();
};
_pendingCloses.set(modal, pending);
content.classList.add('modal-closing');
content.addEventListener('animationend', pending.onEnd);
pending.timer = setTimeout(finish, 250);
}
+11 -10
View File
@@ -128,7 +128,7 @@ async function _ensureDefaultPendingChat() {
if (!_deps || _defaultChatPickInFlight) return;
if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return;
const pending = _deps.getPendingChat && _deps.getPendingChat();
if (pending && pending.modelId) return;
if (pending && pending.modelId && pending.source !== 'fallback') return;
_defaultChatPickInFlight = true;
const seq = ++_defaultPendingSeq;
try {
@@ -954,15 +954,6 @@ export function updateModelPicker() {
}
}
const latestPending = _deps.getPendingChat && _deps.getPendingChat();
if (
!currentSessionId &&
!_autoSelectingDefault &&
window.modelsModule &&
window.modelsModule.getCachedItems &&
(!modelId || (latestPending && latestPending.source === 'fallback'))
) {
_ensureDefaultPendingChat();
}
let displayName = modelId ? modelId.split('/').pop() : 'Select model';
const routeItems = window.modelsModule?.getCachedItems?.() || [];
@@ -994,6 +985,16 @@ export function updateModelPicker() {
label.textContent = displayName;
}
_updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint);
// A cached default can resolve synchronously and re-render the picker.
if (
!currentSessionId &&
!_autoSelectingDefault &&
window.modelsModule &&
window.modelsModule.getCachedItems &&
(!modelId || (latestPending && latestPending.source === 'fallback'))
) {
_ensureDefaultPendingChat();
}
}
// ── Reasoning effort control for models supporting reasoning levels ──
+30 -6
View File
@@ -941,13 +941,32 @@ function _exitHistorySelectMode() {
_renderJobs();
}
async function _deleteResearchFromDisk(id) {
const response = await fetch(`${_apiBase}/api/research/${id}`, { method: 'DELETE', credentials: 'same-origin' });
if (!response.ok) throw new Error('Research deletion failed');
}
function _showDeleteFailure(count) {
if (typeof uiModule !== 'undefined' && uiModule?.showError) {
uiModule.showError(`Could not delete ${count} research ${count === 1 ? 'report' : 'reports'}. Please try again.`);
}
}
async function _deleteHistoryJobs(ids) {
const selected = new Set(ids);
const selectedJobs = jobs.getJobs().filter(job => selected.has(job.id));
await Promise.all(selectedJobs.filter(job => job.status === 'done').map(async job => {
try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {}
let failed = 0;
await Promise.all(selectedJobs.map(async job => {
try {
if (job.status === 'done') await _deleteResearchFromDisk(job.id);
_historySelectedIds.delete(job.id);
jobs.removeJob(job.id);
} catch {
failed++;
}
}));
selectedJobs.forEach(job => jobs.removeJob(job.id));
if (failed) _showDeleteFailure(failed);
return failed === 0;
}
async function _bulkDeleteHistory() {
@@ -957,8 +976,8 @@ async function _bulkDeleteHistory() {
const ok = await window.styledConfirm(`Delete ${ids.length} research ${ids.length === 1 ? 'report' : 'reports'}?`, { confirmText: 'Delete', danger: true });
if (!ok) return;
}
await _deleteHistoryJobs(ids);
_exitHistorySelectMode();
if (await _deleteHistoryJobs(ids)) _exitHistorySelectMode();
else _updateHistoryBulkBar();
}
async function _clearHistory() {
@@ -1296,7 +1315,12 @@ function _buildJobCard(job) {
const ok = await window.styledConfirm('Delete this research? This permanently removes it from disk.', { confirmText: 'Delete', danger: true });
if (!ok) return;
}
try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {}
try {
await _deleteResearchFromDisk(job.id);
} catch {
_showDeleteFailure(1);
return;
}
overflow.close();
_animateOutThenRemove(card, () => jobs.removeJob(job.id));
});
+44 -15
View File
@@ -353,36 +353,38 @@ function _installHistoryPager(id, pageInfo, modelName) {
handler: null,
};
const loadOlder = async () => {
if (!_historyPager || _historyPager.loading || _historyPager.done) return;
if (_historyPager.sessionId !== currentSessionId) return;
if (box.scrollTop > 90) return;
const loadOlder = async (force = false) => {
const pager = _historyPager;
if (!pager || pager.loading || pager.done) return;
if (pager.sessionId !== currentSessionId) return;
if (!force && box.scrollTop > 90) return;
const nextOffset = Math.max(0, _historyPager.offset - _historyPager.limit);
const nextLimit = _historyPager.offset - nextOffset;
const nextOffset = Math.max(0, pager.offset - pager.limit);
const nextLimit = pager.offset - nextOffset;
if (nextLimit <= 0) {
_historyPager.done = true;
pager.done = true;
return;
}
_historyPager.loading = true;
pager.loading = true;
const anchor = box.querySelector('.msg, .agent-thread, .gallery-bubble');
const beforeHeight = box.scrollHeight;
try {
const res = await fetch(_historyUrl(_historyPager.sessionId, { limit: nextLimit, offset: nextOffset }));
const res = await fetch(_historyUrl(pager.sessionId, { limit: nextLimit, offset: nextOffset }));
if (!res.ok) throw new Error(`Chat history request failed (HTTP ${res.status})`);
const data = await res.json();
if (!_historyPager || _historyPager.sessionId !== currentSessionId) return;
if (_historyPager !== pager || pager.sessionId !== currentSessionId) return;
const newEls = [];
for (const msg of data.history || []) {
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
const els = _renderHistoryMessage(msg, _historyPager.modelName);
const els = _renderHistoryMessage(msg, pager.modelName);
if (Array.isArray(els)) newEls.push(...els);
}
for (const el of newEls) {
box.insertBefore(el, anchor || box.firstChild);
}
_historyPager.offset = Number(data.offset || nextOffset);
_historyPager.done = !data.has_more_before;
pager.offset = Number(data.offset || nextOffset);
pager.done = !data.has_more_before;
if (window.hljs) {
newEls.forEach(el => el.querySelectorAll('pre code:not(.hljs)').forEach(block => window.hljs.highlightElement(block)));
}
@@ -391,16 +393,42 @@ function _installHistoryPager(id, pageInfo, modelName) {
} catch (e) {
console.warn('Failed to load older chat history:', e);
} finally {
if (_historyPager) _historyPager.loading = false;
pager.loading = false;
}
};
_historyPager.loadOlder = loadOlder;
_historyPager.handler = () => {
if (box.scrollTop <= 90) loadOlder();
if (box.scrollTop <= 90 && _historyPager && !_historyPager.loading) {
_historyPager.pending = loadOlder();
}
};
box.addEventListener('scroll', _historyPager.handler, { passive: true });
}
export async function prepareHistoryForPrint() {
const pager = _historyPager;
if (!pager) return;
const ensureCurrentChat = () => {
if (_historyPager !== pager || currentSessionId !== pager.sessionId) {
throw new Error('Chat changed while preparing the PDF. Export again from the intended chat.');
}
};
while (!pager.done) {
await pager.pending;
ensureCurrentChat();
if (pager.done) break;
const previousOffset = pager.offset;
pager.pending = pager.loadOlder(true);
await pager.pending;
ensureCurrentChat();
if (!pager.done && pager.offset >= previousOffset) {
throw new Error('Could not load the complete chat for PDF export. Please try again.');
}
}
ensureCurrentChat();
}
function _getIncognitoIds() {
try { return JSON.parse(sessionStorage.getItem(_INCOGNITO_SESSIONS_KEY) || '[]'); } catch { return []; }
}
@@ -4061,6 +4089,7 @@ const sessionModule = {
renderSessionList,
loadSessions,
selectSession,
prepareHistoryForPrint,
createDirectChat,
materializePendingSession,
preMaterializePendingSession,
+8 -20
View File
@@ -6,6 +6,8 @@
import { makeWindowDraggable } from '../windowDrag.js';
import { clearDockSide } from '../modalSnap.js';
import { toggle as restoreMinimizedModal } from '../modalManager.js';
import { showModal, hideModal } from '../modalVisibility.js';
const _dragBound = new WeakSet();
const _closeBound = new WeakSet();
@@ -147,30 +149,16 @@ export function bindSettingsClose(modalEl, options = {}) {
export function showSettingsModal(modalEl) {
if (!modalEl) return;
if (restoreMinimizedModal(modalEl.id)) {
showModal(modalEl);
return;
}
if (modalEl.classList.contains('hidden')) {
resetSettingsWindowPlacement(modalEl);
}
modalEl.classList.remove('hidden');
showModal(modalEl);
}
export function hideSettingsModal(modalEl) {
if (!modalEl) return;
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
if (content && !content.classList.contains('modal-closing')) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}, { once: true });
setTimeout(() => {
if (!modalEl.classList.contains('hidden')) {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}
}, 250);
return;
}
modalEl.classList.add('hidden');
hideModal(modalEl);
}
+34 -8
View File
@@ -999,6 +999,7 @@ function renderSkillsList() {
// the footer too so it's not buried under the "⋯" menu.
const testBtn = document.createElement('button');
testBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
testBtn.dataset.act = 'test';
testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Test';
testBtn.title = 'Test this skill — run it + AI judge';
testBtn.addEventListener('click', (e) => {
@@ -1013,14 +1014,10 @@ function renderSkillsList() {
const _origHTML = testBtn.innerHTML;
testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Starting…';
Promise.resolve(_testSkill(card, name)).finally(() => {
// The preview gets overwritten by _testSkill, which removes the
// testBtn from the DOM. The cleanup below only matters if the
// button still exists (e.g. _testSkill bailed early).
if (document.body.contains(testBtn)) {
testBtn.disabled = false;
testBtn.dataset.busy = '';
testBtn.innerHTML = _origHTML;
}
// Source controls are detached during results and reused by Edit.
testBtn.disabled = false;
testBtn.dataset.busy = '';
testBtn.innerHTML = _origHTML;
});
});
@@ -1285,6 +1282,10 @@ function _toggleSkillEdit(card, name) {
if (!card._mdLoaded) return;
const preview = card.querySelector('.skill-card-preview');
if (!preview) return;
if (card._skillSourceNodes) {
preview.replaceChildren(...card._skillSourceNodes);
card._skillSourceNodes = null;
}
const existing = preview.querySelector('.skill-md-editor');
if (existing) {
// Already editing — treat Edit as Save.
@@ -1305,7 +1306,26 @@ function _toggleSkillEdit(card, name) {
ta.focus();
// Flip the Edit button label to "Save".
const editBtn = [...preview.querySelectorAll('.doclib-card-action-btn')].find(b => /Edit|Save/.test(b.textContent));
const editLabel = editBtn?.innerHTML;
if (editBtn) editBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'doclib-card-text-btn doclib-card-action-btn';
cancel.textContent = 'Cancel';
const testBtn = preview.querySelector('[data-act="test"]');
const testDisplay = testBtn?.style.display;
if (testBtn) testBtn.style.display = 'none';
card._cancelSkillEdit = () => {
ta.remove();
if (pre) pre.style.removeProperty('display');
if (editBtn) editBtn.innerHTML = editLabel;
cancel.remove();
if (testBtn) testBtn.style.display = testDisplay;
card._cancelSkillEdit = null;
card._fillH?.();
};
cancel.addEventListener('click', (e) => { e.stopPropagation(); card._cancelSkillEdit?.(); });
if (editBtn) editBtn.after(cancel);
}
async function _saveSkillEdit(card, name) {
@@ -1454,6 +1474,12 @@ async function _testSkill(card, name, force = false) {
if (!card.classList.contains('doclib-card-expanded')) await _expandSkillCard(card, name);
const preview = card.querySelector('.skill-card-preview');
if (!preview) return;
if (!preview.querySelector('.skill-test')) {
card._cancelSkillEdit?.();
// Preserve the source nodes and handlers. Result actions are nested in
// .skill-test, so they cannot be insertBefore references on the preview.
card._skillSourceNodes = Array.from(preview.childNodes);
}
preview.innerHTML =
'<div class="skill-test"><div class="skill-test-log"></div>' +
'<div class="skill-test-verdict"></div></div>';
+16 -16
View File
@@ -7,6 +7,8 @@ import { initColorPickers, attachColorPicker } from './colorPicker.js?v=20260910
import { hexToRgb } from './color/hex.js';
import { makeWindowDraggable } from './windowDrag.js';
import { snapModalToZone } from './tileManager.js?v=20260910responsivebounds1';
import { toggle as restoreMinimizedModal } from './modalManager.js';
import { showModal, hideModal } from './modalVisibility.js';
export const THEMES = {
dark: { bg:'#282c34', fg:'#9cdef2', panel:'#111111', border:'#355a66', red:'#e06c75' },
@@ -1570,31 +1572,29 @@ export function makeDraggable(el, handle) {
}
// Toggle the popup
export function openPopup() {
const modal = document.getElementById('theme-modal');
if (!modal) return;
restoreMinimizedModal(modal.id);
showModal(modal);
}
export function togglePopup() {
const modal = document.getElementById('theme-modal');
if (!modal) return;
const visible = !modal.classList.contains('hidden');
const visible = !modal.classList.contains('hidden')
&& !modal.classList.contains('modal-minimized')
&& !modal.querySelector('.modal-closing');
if (visible) {
modal.classList.add('hidden');
closePopup();
} else {
modal.classList.remove('hidden');
openPopup();
}
}
export function closePopup() {
const modal = document.getElementById('theme-modal');
if (!modal) return;
const content = modal.querySelector('.modal-content');
if (content && !content.classList.contains('modal-closing')) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
modal.classList.add('hidden');
content.classList.remove('modal-closing');
}, { once: true });
setTimeout(() => { if (!modal.classList.contains('hidden')) { modal.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250);
} else {
modal.classList.add('hidden');
}
hideModal(modal);
}
// Expose for app.js wiring + AI ui_control
@@ -2278,7 +2278,7 @@ function _initEmbers() {
draw();
}
const themeModule = { initThemeUI, togglePopup, closePopup, makeDraggable,
const themeModule = { initThemeUI, openPopup, togglePopup, closePopup, makeDraggable,
THEMES, applyColors, applyFontDensity, applyBgPattern,
applyBgEffectColor, applyBgEffectIntensity, applyBgEffectSize, applyBgEffectSpeed,
applyFrostedGlass,
+4 -2
View File
@@ -776,14 +776,16 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
if (titleEl) titleEl.textContent = title || 'Confirm';
msgEl.textContent = message;
// Labels sit in spans so CSS can nudge the text without moving the button.
const btnLabel = (text) => Object.assign(document.createElement('span'), { className: 'styled-confirm-btn-label', textContent: text });
// Use the affirmative label consistently for generic confirmations.
okBtn.textContent = confirmText === 'OK' ? 'Yes' : confirmText;
okBtn.replaceChildren(btnLabel(confirmText === 'OK' ? 'Yes' : confirmText));
cancelBtn.textContent = '';
const cancelLabel = document.createElement('span');
cancelLabel.className = 'styled-confirm-cancel-label';
cancelLabel.textContent = cancelText;
cancelBtn.appendChild(cancelLabel);
altBtn.textContent = alternateText || '';
altBtn.replaceChildren(btnLabel(alternateText || ''));
okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary';
cancelBtn.className = 'confirm-btn confirm-btn-secondary';
altBtn.className = 'confirm-btn confirm-btn-secondary';
+24 -23
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v1135-pr40-editor-precache';
const CACHE_NAME = 'odysseus-v1137-pr40-editor-precache';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -38,30 +38,31 @@ const KATEX_FONTS = [
// Both are fetched at install time, in the background. Entries must match the
// exact URL the browser requests, query string included.
const PRECACHE = [
'/static/js/modalVisibility.js',
'/static/js/compare/sse.js',
'/',
'/static/css/00-tokens.css?v=20261007detailscroll1',
'/static/css/01-agent-chat.css?v=20261007detailscroll1',
'/static/css/02-compare.css?v=20261007detailscroll1',
'/static/css/03-agent-chat.css?v=20261007detailscroll1',
'/static/css/04-memory.css?v=20261007detailscroll1',
'/static/css/05-documents.css?v=20261007detailscroll1',
'/static/css/06-admin-settings.css?v=20261007detailscroll1',
'/static/css/07-documents.css?v=20261007detailscroll1',
'/static/css/08-skills.css?v=20261007detailscroll1',
'/static/css/09-gallery.css?v=20261007detailscroll1',
'/static/css/10-cookbook.css?v=20261007detailscroll1',
'/static/css/11-tasks.css?v=20261007detailscroll1',
'/static/css/12-gallery.css?v=20261007detailscroll1',
'/static/css/13-image-editor.css?v=20261007detailscroll1',
'/static/css/14-email.css?v=20261007detailscroll1',
'/static/css/15-notes.css?v=20261007detailscroll1',
'/static/css/16-calendar.css?v=20261007detailscroll1',
'/static/css/17-research.css?v=20261007detailscroll1',
'/static/css/documents-gallery-editor.css?v=20261007detailscroll1',
'/static/css/email-calendar-notes-tasks.css?v=20261007detailscroll1',
'/static/css/cookbook-research-memory-settings.css?v=20261007detailscroll1',
'/static/app.js?v=20261007detailscroll1',
'/static/css/00-tokens.css?v=20261021confirmalign1',
'/static/css/01-agent-chat.css?v=20261021confirmalign1',
'/static/css/02-compare.css?v=20261021confirmalign1',
'/static/css/03-agent-chat.css?v=20261021confirmalign1',
'/static/css/04-memory.css?v=20261021confirmalign1',
'/static/css/05-documents.css?v=20261021confirmalign1',
'/static/css/06-admin-settings.css?v=20261021confirmalign1',
'/static/css/07-documents.css?v=20261021confirmalign1',
'/static/css/08-skills.css?v=20261021confirmalign1',
'/static/css/09-gallery.css?v=20261021confirmalign1',
'/static/css/10-cookbook.css?v=20261021confirmalign1',
'/static/css/11-tasks.css?v=20261021confirmalign1',
'/static/css/12-gallery.css?v=20261021confirmalign1',
'/static/css/13-image-editor.css?v=20261021confirmalign1',
'/static/css/14-email.css?v=20261021confirmalign1',
'/static/css/15-notes.css?v=20261021confirmalign1',
'/static/css/16-calendar.css?v=20261021confirmalign1',
'/static/css/17-research.css?v=20261021confirmalign1',
'/static/css/documents-gallery-editor.css?v=20261021confirmalign1',
'/static/css/email-calendar-notes-tasks.css?v=20261021confirmalign1',
'/static/css/cookbook-research-memory-settings.css?v=20261021confirmalign1',
'/static/app.js?v=20261021confirmalign1',
'/static/js/storage.js',
'/static/js/appConfig.js',
'/static/js/ui.js?v=20260916largetoolscroll1',
+1 -1
View File
@@ -3,7 +3,7 @@ from tests.helpers.db_stubs import make_core_db_stub
def test_text_len_ignores_non_string_values(monkeypatch):
make_core_db_stub(monkeypatch, models=["Document", "DocumentVersion"])
make_core_db_stub(monkeypatch, models=["Document", "DocumentVersion", "ChatMessage", "Session"])
cli = load_script("odysseus-docs")
assert cli._text_len("hello") == 5
+103
View File
@@ -0,0 +1,103 @@
"""Regression for #2123 (CLI part): `odysseus-docs search` must match
multi-word queries and treat % / _ literally.
Old code built one ``%{query}%`` LIKE over title/current_content, so
"machine learning" only matched the exact adjacent phrase, and a literal
"%" matched every document.
"""
import json
from types import SimpleNamespace
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
import core.database as cdb
from core.database import Document
from tests.helpers.cli_loader import load_script
def _cli_with_documents(monkeypatch, tmp_path):
engine = create_engine(
f"sqlite:///{tmp_path / 'docs_cli_search.db'}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
db = session_factory()
try:
db.add_all(
[
Document(
id="doc-multiword",
title="ML notes",
current_content="machine notes on learning",
),
Document(
id="doc-percent",
title="Fabric",
current_content="100% cotton",
),
Document(
id="doc-underscore",
title="sale_2024",
current_content="spring campaign",
),
Document(
id="doc-backslash",
title="Path",
current_content=r"C:\photos\sunset",
),
]
)
db.commit()
finally:
db.close()
cli = load_script("odysseus-docs")
monkeypatch.setattr(cli, "SessionLocal", session_factory)
return cli
def _search_ids(cli, capsys, query):
cli.cmd_search(SimpleNamespace(query=query, limit=50, pretty=False))
return {doc["id"] for doc in json.loads(capsys.readouterr().out)}
def test_search_matches_words_in_any_order(monkeypatch, tmp_path, capsys):
cli = _cli_with_documents(monkeypatch, tmp_path)
assert _search_ids(cli, capsys, "machine learning") == {"doc-multiword"}
def test_search_escapes_percent_wildcard(monkeypatch, tmp_path, capsys):
cli = _cli_with_documents(monkeypatch, tmp_path)
assert _search_ids(cli, capsys, "%") == {"doc-percent"}
def test_search_escapes_underscore_wildcard(monkeypatch, tmp_path, capsys):
cli = _cli_with_documents(monkeypatch, tmp_path)
# Unescaped, "_" matches any single char so every document matches.
assert _search_ids(cli, capsys, "_") == {"doc-underscore"}
def test_search_escapes_backslash(monkeypatch, tmp_path, capsys):
cli = _cli_with_documents(monkeypatch, tmp_path)
assert _search_ids(cli, capsys, "\\") == {"doc-backslash"}
def test_search_whitespace_query_returns_all(monkeypatch, tmp_path, capsys):
cli = _cli_with_documents(monkeypatch, tmp_path)
assert _search_ids(cli, capsys, " ") == {
"doc-multiword",
"doc-percent",
"doc-underscore",
"doc-backslash",
}
+3
View File
@@ -348,6 +348,8 @@ function moduleSource(relativePath) {
},
makeWindowDraggable: (...args) => dragCalls.push(args),
clearDockSide: (...args) => dockCalls.push(args),
restoreMinimizedModal: () => false,
clearTimeout() {},
setTimeout: callback => { callback(); return 1; },
WeakSet,
};
@@ -356,6 +358,7 @@ function moduleSource(relativePath) {
vm.runInContext(moduleSource('search.js'), context, { filename: 'search.js' });
vm.runInContext(moduleSource('sidebar.js'), context, { filename: 'sidebar.js' });
vm.runInContext(moduleSource('navigation.js'), context, { filename: 'navigation.js' });
vm.runInContext(moduleSource('../modalVisibility.js'), context, { filename: 'modalVisibility.js' });
vm.runInContext(moduleSource('lifecycle.js'), context, { filename: 'lifecycle.js' });
vm.runInContext(moduleSource('dom.js'), context, { filename: 'dom.js' });
@@ -20,6 +20,7 @@ const REAL_MODULES = new Set([
path.join(JS, 'settings/sidebar.js'),
path.join(JS, 'settings/navigation.js'),
path.join(JS, 'settings/lifecycle.js'),
path.join(JS, 'modalVisibility.js'),
path.join(JS, 'settings/api.js'),
path.join(JS, 'settings/speech.js'),
path.join(JS, 'settings/writingStyle.js'),
@@ -869,6 +870,7 @@ const STUBS = new Map([
clearDockSide() {},
},
],
[path.join(JS, 'modalManager.js'), { toggle() { return false; } }],
]);
+52
View File
@@ -0,0 +1,52 @@
"""New usernames must be usable as a URL path segment and not carry control characters."""
import asyncio
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from tests.test_auth_policy import _auth_module, _make_manager, _setup_endpoint, _signup_endpoint
BAD = ["a/b", "../x", "a\\b", "tab\tname", "new\nline", "..", ".", "u" * 65]
@pytest.mark.parametrize("name", BAD)
def test_username_error_rejects(name):
assert _auth_module().username_error(name)
@pytest.mark.parametrize("name", ["alice", "Bob Smith", "j.doe", "user@example.com", "日本語", "u" * 64])
def test_username_error_accepts(name):
assert _auth_module().username_error(name) is None
def test_username_error_requires_a_name():
assert _auth_module().username_error(" ") == "Username is required"
@pytest.mark.parametrize("name", ["a/b", "u" * 65])
def test_setup_rejects_bad_username(tmp_path, name):
mgr = _make_manager(tmp_path)
endpoint, SetupRequest = _setup_endpoint(mgr)
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
with pytest.raises(HTTPException) as exc:
asyncio.run(endpoint(body=SetupRequest(username=name, password="long-enough-pw"), request=request))
assert exc.value.status_code == 400
assert not mgr.is_configured
def test_signup_rejects_bad_username(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user("admin", "admin-password", is_admin=True)
mgr.signup_enabled = True
endpoint, SignupRequest = _signup_endpoint(mgr)
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
with pytest.raises(HTTPException) as exc:
asyncio.run(endpoint(body=SignupRequest(username="a/b", password="long-enough-pw"), request=request))
assert exc.value.status_code == 400
assert "a/b" not in mgr.users
+61
View File
@@ -0,0 +1,61 @@
"""The shipped CSS must print every message beyond the screen viewport."""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from pypdf import PdfReader
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.skipif(not shutil.which("node"), reason="node is required for Chromium printing")
def test_chat_print_preserves_the_complete_transcript(tmp_path):
script = r'''
import fs from 'node:fs';
import path from 'node:path';
import { chromium } from 'playwright';
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
const source = fs.readFileSync('static/index.html', 'utf8');
const links = [...source.matchAll(/<link\b[^>]*rel="stylesheet"[^>]*>/g)]
.map(match => match[0]).join('\n');
const messages = Array.from({length: 24}, (_, index) => {
const marker = 'EXPORTSENTINEL' + String(index + 1).padStart(2, '0');
const paragraphs = Array.from({length:4}, () =>
'<p>This synthetic message must survive printing beyond the screen viewport. '
+ 'Every paragraph belongs to the saved conversation.</p>').join('');
return `<div class="msg ${index % 2 ? 'msg-ai' : 'msg-user'}"><div class="body"><p>${marker}</p>${paragraphs}</div></div>`;
}).join('');
const browser = await chromium.launch({headless:true});
try {
const results=[];
for (const [layout,viewport] of [['desktop',{width:1440,height:1000}],['mobile',{width:390,height:844}]]) {
const page=await browser.newPage({viewport});
await page.goto(origin+'/static/css/00-tokens.css');
await page.setContent(`<html><head>${links}</head><body><main class="chat-container"><div id="chat-history">${messages}</div></main></body></html>`,{waitUntil:'load'});
await page.evaluate(()=>document.fonts.ready);
const file=path.join(process.env.ODYSSEUS_PDF_OUTPUT_DIR,layout+'.pdf');
await page.pdf({path:file,format:'A4',printBackground:true});
results.push({layout,file});
await page.close();
}
console.log(JSON.stringify(results));
} finally {await browser.close();}
'''
result = subprocess.run(
["node", "--input-type=module", "-e", script], cwd=ROOT,
capture_output=True, text=True, timeout=90,
env={**os.environ, "ODYSSEUS_PDF_OUTPUT_DIR": str(tmp_path)},
)
assert result.returncode == 0, result.stderr
results = json.loads(result.stdout)
assert {row["layout"] for row in results} == {"desktop", "mobile"}
for row in results:
pdf = PdfReader(row["file"])
text = "".join("".join(page.extract_text().split()) for page in pdf.pages)
assert len(pdf.pages) > 1, row["layout"]
for index in range(1, 25):
assert f"EXPORTSENTINEL{index:02d}" in text, (row["layout"], index)
+34
View File
@@ -2,6 +2,7 @@ import json
import os
import subprocess
import sys
import re
from pathlib import Path
import pytest
@@ -1026,3 +1027,36 @@ def test_validate_serve_cmd_rejects_unrelated_subshell_pipelines():
]:
with pytest.raises(HTTPException):
_validate_serve_cmd(cmd)
def _dependency_recipe_pip_commands():
src = (Path(__file__).resolve().parents[1] / "static" / "js" / "cookbook-deps-recipes.js").read_text(encoding="utf-8")
return re.findall(r"pip:\s*\{\s*commands:\s*\['((?:[^'\\]|\\.)*)'\]\s*\}", src)
def test_validate_serve_cmd_accepts_single_line_dependency_recipes():
# The Dependencies recipe "Run" button posts these to /api/model/serve.
cmds = _dependency_recipe_pip_commands()
assert 'uv pip install -U vllm --torch-backend auto' in cmds
assert 'uv pip install -U "sglang[all]" --torch-backend auto' in cmds
for cmd in cmds:
assert _validate_serve_cmd(cmd) == cmd
@pytest.mark.parametrize("cmd", [
"uv run python -c 'print(1)'",
"uv tool install ruff",
"uv pip uninstall vllm",
"uv",
])
def test_validate_serve_cmd_allows_uv_only_for_pip_install(cmd):
with pytest.raises(HTTPException):
_validate_serve_cmd(cmd)
def test_dependency_recipe_run_sends_a_valid_pip_repo_id():
# A pip-install repo_id must match the server's package-spec regex; the old
# "<backend> setup" value had a space and every Run got "Invalid pip package name".
src = (Path(__file__).resolve().parents[1] / "static" / "js" / "cookbook.js").read_text(encoding="utf-8")
assert "repo_id: `${backend}-setup`," in src
assert "repo_id: `${backend} setup`" not in src
@@ -0,0 +1,82 @@
"""Demoting an admin purges their API tokens (issue #6831).
Token management is admin-only and scoped to the token's owner, so once the
owner is demoted nobody can list or revoke their tokens any more.
"""
import contextlib
import sys
import types
import pytest
from tests.test_delete_user_revokes_api_tokens import (
_auth_module,
_FakeApiToken,
_FakeSession,
)
@pytest.fixture
def manager(tmp_path, monkeypatch):
auth_mod = _auth_module()
monkeypatch.setattr(auth_mod, "_hash_password", lambda password: f"hash:{password}")
monkeypatch.setattr(
auth_mod, "_verify_password", lambda password, hashed: hashed == f"hash:{password}"
)
mgr = auth_mod.AuthManager(str(tmp_path / "auth.json"))
assert mgr.create_user("admin", "secret-admin-pw", is_admin=True)
assert mgr.create_user("adm2", "secret-adm2-pw", is_admin=True)
assert mgr.create_user("bob", "secret-bob-pw", is_admin=False)
return mgr
@pytest.fixture
def db_calls(monkeypatch):
calls = []
@contextlib.contextmanager
def _fake_db_session():
yield _FakeSession(calls)
db_stub = types.ModuleType("core.database")
db_stub.get_db_session = _fake_db_session
db_stub.ApiToken = _FakeApiToken
monkeypatch.setitem(sys.modules, "core.database", db_stub)
return calls
def test_demote_revokes_api_tokens(manager, db_calls):
result = manager.set_admin("adm2", False, "admin")
assert result.value == "ok"
assert manager.is_admin("adm2") is False
assert [("owner ==", "adm2")] in db_calls
def test_promote_leaves_api_tokens_alone(manager, db_calls):
manager.set_admin("bob", True, "admin")
assert db_calls == []
def test_refused_demotion_leaves_api_tokens_alone(manager, db_calls):
assert manager.set_admin("adm2", False, "bob").value == "not_authorized"
assert db_calls == []
def test_demote_fails_closed_when_token_purge_fails(manager, monkeypatch):
@contextlib.contextmanager
def _failing_db_session():
raise RuntimeError("database unavailable")
yield
db_stub = types.ModuleType("core.database")
db_stub.get_db_session = _failing_db_session
db_stub.ApiToken = _FakeApiToken
monkeypatch.setitem(sys.modules, "core.database", db_stub)
with pytest.raises(RuntimeError):
manager.set_admin("adm2", False, "admin")
assert manager.is_admin("adm2") is True
+32
View File
@@ -58,6 +58,38 @@ def test_styled_confirm_cancel_or_close_label_is_shifted_without_moving_button()
assert "top:2px;" in rule
def test_styled_confirm_ok_and_alternate_labels_shift_without_moving_buttons():
# Moving #styled-confirm-ok itself (position:relative; top:2px) left it 2px
# below Cancel and the alternate button. The OK and alternate labels get the
# same label nudge as Cancel instead, and no rule moves a footer button.
css = app_css()
assert "okBtn.replaceChildren(btnLabel(" in _UI
assert "altBtn.replaceChildren(btnLabel(" in _UI
rule_start = css.index("#styled-confirm-ok .styled-confirm-btn-label")
rule = css[rule_start:css.index("}", rule_start)]
assert "#styled-confirm-alt .styled-confirm-btn-label" in rule
assert "position:relative;" in rule and "top:2px;" in rule
for selector in ("#styled-confirm-ok {", "#styled-confirm-alt {", "#styled-confirm-cancel {"):
if selector in css:
block = css[css.index(selector):].split("}", 1)[0]
assert "top:" not in block.replace(" ", ""), selector
def test_styled_confirm_buttons_grow_to_fit_wrapped_labels():
# The global `button { height:32px }` held a two-line label (e.g.
# "Delete chat and images") to one line's height, so its second line
# rendered below the button. Confirm buttons must be allowed to grow,
# with 32px kept as the minimum.
css = app_css()
assert re.search(r"(^|\n)\s*button\s*\{\s*height:32px;", css)
rule_start = css.index(".styled-confirm-box .confirm-btn {")
rule = css[rule_start:rule_start + 120]
assert "height:auto;" in rule
assert "min-height:32px;" in rule
def test_styled_dialogs_manage_focus():
# A dialog is only really accessible if it restores focus to the trigger on
# close and traps Tab while open. Both styledConfirm and styledPrompt should
+95
View File
@@ -0,0 +1,95 @@
"""Large document pages survive the model-facing tool-output boundary."""
import asyncio
import json
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.constants import MAX_OUTPUT_CHARS
from src.tool_execution import format_tool_result
from core import database
from src.agent_tools import TOOL_HANDLERS
from src.agent_tools.document_tools import get_active_document, set_active_document
@pytest.fixture
def documents(tmp_path, monkeypatch):
engine = create_engine(f"sqlite:///{tmp_path / 'documents.db'}")
database.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
monkeypatch.setattr(database, "SessionLocal", factory)
saved_active = get_active_document()
with factory() as db:
db.add(database.Document(id="owned-document", owner="fixture-owner", title="Owned",
language="text", current_content=""))
db.commit()
yield
set_active_document(saved_active)
engine.dispose()
@pytest.mark.parametrize("limit", [None, 37, 20_000])
def test_formatted_document_pages_reconstruct_the_entire_body(documents, limit):
body = "".join(f"Row {i}: α, quote=\" and slash=\\\n" for i in range(1200))
with database.SessionLocal() as db:
db.query(database.Document).filter_by(id="owned-document").first().current_content = body
db.commit()
offset = 0
chunks = []
while True:
args = {"action": "read", "document_id": "owned-document", "offset": offset}
if limit is not None:
args["limit"] = limit
content = json.dumps(args)
result = asyncio.run(TOOL_HANDLERS["manage_documents"](content, {"owner": "fixture-owner"}))
page = result["document"]
rendered = format_tool_result(f"manage_documents: {content[:80]}", result)
end = page["next_offset"] if page["truncated"] else len(body)
chunk = body[offset:end]
assert chunk in rendered
assert len(rendered) <= MAX_OUTPUT_CHARS
assert rendered.endswith("\n```")
if page["truncated"]:
assert end > offset
assert f"next_offset={end}" in rendered
chunks.append(chunk)
if not page["truncated"]:
break
offset = end
assert "".join(chunks) == body
def test_document_result_cannot_bypass_the_output_cap():
rendered = format_tool_result("manage_documents: read", {
"response": "x" * (MAX_OUTPUT_CHARS * 2),
"document": {"offset": 0, "next_offset": None},
"exit_code": 0,
})
assert len(rendered) < MAX_OUTPUT_CHARS + 100
@pytest.mark.parametrize("body", ["", "short document"])
@pytest.mark.parametrize("offset", [0, 100])
def test_short_or_exhausted_read_has_no_continuation(documents, body, offset):
with database.SessionLocal() as db:
db.query(database.Document).filter_by(id="owned-document").first().current_content = body
db.commit()
result = asyncio.run(TOOL_HANDLERS["manage_documents"](json.dumps({
"action": "read", "document_id": "owned-document", "offset": offset,
}), {"owner": "fixture-owner"}))
assert result["document"]["content"] == body[offset:]
assert result["document"]["next_offset"] is None
assert not result["document"]["truncated"]
assert format_tool_result("manage_documents: read", result) == result["response"]
def test_oversized_header_fails_without_a_nonadvancing_page(documents):
with database.SessionLocal() as db:
db.query(database.Document).filter_by(id="owned-document").first().title = "x" * MAX_OUTPUT_CHARS
db.commit()
result = asyncio.run(TOOL_HANDLERS["manage_documents"](
'{"action":"read","document_id":"owned-document"}', {"owner": "fixture-owner"}))
assert result["exit_code"] == 1
assert "too long" in result["error"]
+80
View File
@@ -0,0 +1,80 @@
"""MIME alternatives are selected independently of part order and codec labels."""
from email import message_from_bytes
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import pytest
from mcp_servers.email_server import _extract_text as mcp_text
from routes.email_helpers import _extract_html, _extract_text as route_text
def alternative(order, plain='Body text', html='<p>Body text</p>'):
msg = MIMEMultipart('alternative')
parts = {'plain': MIMEText(plain, 'plain', 'utf-8'), 'html': MIMEText(html, 'html', 'utf-8')}
for kind in order:
msg.attach(parts[kind])
return message_from_bytes(msg.as_bytes())
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
@pytest.mark.parametrize('order', [('html', 'plain'), ('plain', 'html')])
def test_plain_alternative_wins_in_either_order(extract, order):
assert extract(alternative(order)) == 'Body text'
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
def test_empty_plain_allows_html_fallback(extract):
assert extract(alternative(('plain', 'html'), plain='', html='Hello<br>world &amp; all')) == 'Hello\nworld & all'
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
def test_text_attachments_are_not_body_alternatives(extract):
msg = MIMEMultipart('mixed')
attachment = MIMEText('Attachment text', 'plain')
attachment.add_header('Content-Disposition', 'attachment', filename='note.txt')
msg.attach(attachment)
msg.attach(MIMEText('<p>Body text</p>', 'html'))
assert extract(msg) == 'Body text'
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
def test_multiple_plain_body_parts_remain_joined(extract):
msg = MIMEMultipart('mixed')
msg.attach(MIMEText('Part one', 'plain'))
msg.attach(MIMEText('Part two', 'plain'))
assert extract(msg) == 'Part one\nPart two'
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
@pytest.mark.parametrize('kind', ['plain', 'html'])
@pytest.mark.parametrize('multipart', [False, True])
def test_unknown_charset_falls_back_to_utf8(extract, kind, multipart):
body = 'Hello café' if kind == 'plain' else '<p>Hello café</p>'
raw = f'Content-Type: text/{kind}; charset=x-unknown\r\nContent-Transfer-Encoding: 8bit\r\n\r\n'.encode()+body.encode()
part = message_from_bytes(raw)
if multipart:
msg = MIMEMultipart('mixed');msg.attach(part)
else:
msg = part
assert extract(msg) == 'Hello café'
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
def test_declared_latin1_is_preserved(extract):
assert extract(MIMEText('café', 'plain', 'iso-8859-1')) == 'café'
@pytest.mark.parametrize('multipart', [False, True])
def test_raw_html_unknown_charset_is_readable(multipart):
part = message_from_bytes(b'Content-Type: text/html; charset=x-unknown\r\n\r\n<p>Hello caf\xc3\xa9</p>')
if multipart:
msg=MIMEMultipart('mixed');msg.attach(part)
else:msg=part
assert _extract_html(msg) == '<p>Hello café</p>'
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
def test_invalid_bytes_are_replaced_in_unknown_charset(extract):
msg=message_from_bytes(b'Content-Type: text/plain; charset=x-unknown\r\n\r\nHello\xff')
assert extract(msg) == 'Hello\ufffd'
@@ -0,0 +1,62 @@
import asyncio
import pytest
from fastapi import HTTPException
from tests.helpers.database import isolated_session_database
@pytest.fixture
def manager_database(tmp_path):
with isolated_session_database(tmp_path) as resources:
yield resources
def add_message(sm, monkeypatch, body):
import routes.history.history_routes as history
monkeypatch.setattr(history, '_verify_session_owner', lambda *args, **kwargs: None)
monkeypatch.setattr(history, 'effective_user', lambda request: 'u')
monkeypatch.setattr(history, 'reserve_message_upload_references', lambda *args, **kwargs: None)
route = next(r for r in history.setup_history_routes(sm).routes
if r.path == '/api/session/{session_id}/message')
class Request:
async def json(self):
return body
return asyncio.run(route.endpoint(Request(), 'sid'))
@pytest.mark.parametrize('body', [
{'role': 'bogus', 'content': 'x'},
{'role': 'system', 'content': 'x'},
{'role': 'user', 'content': 123},
{'content': ['a']},
{'role': ['user'], 'content': 'x'},
[1],
])
def test_bad_role_or_content_is_a_client_error(manager_database, monkeypatch, body):
sm, _ = manager_database
sm.create_session(session_id='sid', name='t', endpoint_url='x', model='m', rag=False, owner='u')
with pytest.raises(HTTPException) as exc:
add_message(sm, monkeypatch, body)
assert exc.value.status_code == 400
assert sm.get_session('sid').history == []
@pytest.mark.parametrize('body, role', [
({'role': 'user', 'content': 'hi'}, 'user'),
({'role': 'assistant', 'content': 'hello'}, 'assistant'),
({'content': 'default role'}, 'assistant'),
])
def test_user_and_assistant_text_are_stored(manager_database, monkeypatch, body, role):
sm, _ = manager_database
sm.create_session(session_id='sid', name='t', endpoint_url='x', model='m', rag=False, owner='u')
assert add_message(sm, monkeypatch, body) == {'status': 'ok'}
stored = sm.get_session('sid').history
assert [(m.role, m.content) for m in stored] == [(role, body['content'])]
+87
View File
@@ -0,0 +1,87 @@
"""CPU-only hardware results and SSH probe context agree with GPU paths."""
import json
import pytest
from services.hwfit import hardware
WINDOWS_PROBE = hardware._detect_windows
@pytest.fixture(autouse=True)
def probe(monkeypatch):
monkeypatch.setattr(hardware, "_cache_by_host", {})
for name in ("_remote_host", "_remote_port", "_remote_platform", "_last_gpu_error"):
monkeypatch.setattr(hardware, name, None)
monkeypatch.setattr(hardware, "_get_ram_gb", lambda: 64.0)
monkeypatch.setattr(hardware, "_get_available_ram_gb", lambda: 48.0)
monkeypatch.setattr(hardware, "_get_cpu_count", lambda: 16)
monkeypatch.setattr(hardware, "_get_cpu_name", lambda: "Test CPU")
monkeypatch.setattr(hardware, "_get_cpu_arch", lambda: "x86_64")
monkeypatch.setattr(hardware, "_is_containerized", lambda: False)
for name in ("_detect_windows", "_detect_apple_silicon", "_detect_nvidia", "_detect_amd"):
monkeypatch.setattr(hardware, name, lambda: None)
@pytest.mark.parametrize("arch,backend", [("x86_64", "cpu_x86"), ("arm64", "cpu_arm")])
@pytest.mark.parametrize("remote", [False, True])
def test_cpu_only_result_has_empty_gpu_inventory(monkeypatch, arch, backend, remote):
monkeypatch.setattr(hardware, "_get_cpu_arch", lambda: arch)
options = {"host": "user@cpu-fixture", "ssh_port": "2222", "platform": "linux"} if remote else {}
result = hardware.detect_system(**options)
assert result["has_gpu"] is False
assert result["gpu_count"] == 0
assert result["gpus"] == []
assert result["gpu_groups"] == []
assert result["homogeneous"] is True
assert result["backend"] == backend
assert result["cpu_arch"] == arch
assert hardware.detect_system(**options) == result
def test_windows_cpu_only_result_has_empty_gpu_inventory(monkeypatch):
monkeypatch.setattr(hardware, "_detect_windows", WINDOWS_PROBE)
monkeypatch.setattr(hardware, "_run", lambda _command: json.dumps({
"ram_gb": 64.0, "avail_gb": 48.0, "cpu_cores": 16,
"cpu_name": "Test CPU", "cpu_arch": "AMD64", "gpu_count": 0,
}))
result = hardware.detect_system(host="user@windows-fixture", ssh_port="2222", platform="windows")
assert result["has_gpu"] is False
assert result["gpus"] == []
assert result["gpu_groups"] == []
assert result["homogeneous"] is True
@pytest.mark.parametrize("path", ["cpu", "gpu", "windows", "windows_failure", "connection_failure"])
def test_remote_probe_clears_all_connection_context(monkeypatch, path):
gpu = {
"gpu_name": "Test GPU", "gpu_vram_gb": 16.0, "gpu_count": 1,
"gpus": [{"index": 0, "name": "Test GPU", "vram_gb": 16.0}],
"gpu_groups": [{"name": "Test GPU", "count": 1}],
"homogeneous": True, "backend": "cuda",
}
if path == "gpu":
monkeypatch.setattr(hardware, "_detect_nvidia", lambda: gpu)
elif path == "windows":
monkeypatch.setattr(hardware, "_detect_windows", lambda: {"has_gpu": False, "backend": "cpu_x86"})
elif path == "connection_failure":
monkeypatch.setattr(hardware, "_get_ram_gb", lambda: 0.0)
result = hardware.detect_system(
host="user@hardware-fixture", ssh_port="2222",
platform="windows" if path.startswith("windows") else "linux", fresh=True,
)
if path in {"windows_failure", "connection_failure"}:
assert result["error"]
elif path == "gpu":
assert result["gpus"] == gpu["gpus"]
assert result["gpu_groups"] == gpu["gpu_groups"]
assert result["backend"] == "cuda"
else:
assert result["has_gpu"] is False
assert (hardware._remote_host, hardware._remote_port, hardware._remote_platform) == (None, None, None)
+52
View File
@@ -0,0 +1,52 @@
"""Native memory ranking must not promote names on substring/first-person guesses."""
import pytest
from src.memory import MemoryManager
@pytest.fixture
def manager(tmp_path):
return MemoryManager(str(tmp_path))
@pytest.mark.parametrize('query', ['bitcoin price', 'list tasks', 'time forecast', 'summary'])
def test_unrelated_query_does_not_force_name_into_results(manager, query):
name = {'id':'name', 'text':'My name is Rowan Example'}
match = {'id':'match', 'text':query}
assert manager.get_relevant_memories(query, [name, match]) == [match]
@pytest.mark.parametrize('query, text', [
('what is my email', 'email is contact@example.test'),
('I like jazz', 'I like jazz music'),
('remind me about dentist', 'dentist appointment reminder'),
])
def test_first_person_words_do_not_override_the_actual_topic(manager, query, text):
name = {'id':'name', 'text':'My name is Rowan Example'}
topic = {'id':'topic', 'text':text}
assert manager.get_relevant_memories(query, [name, topic], max_items=1) == [topic]
@pytest.mark.parametrize('query', ['Who am I?', 'what am I called', 'my name', 'NAME:'])
def test_explicit_identity_query_still_finds_name(manager, query):
name = {'id':'name', 'text':'My name is Rowan Example'}
assert manager.get_relevant_memories(query, [name]) == [name]
def test_closer_match_outranks_identity_signal_and_threshold_still_applies(manager):
name = {'id':'name', 'text':'My name is Rowan Example'}
project = {'id':'project', 'text':'the project name is Polaris'}
assert manager.get_relevant_memories('what is the project name', [name, project], max_items=1) == [project]
assert manager.get_relevant_memories('who am I', [name], threshold=0.7) == []
def test_name_is_still_searchable_by_its_actual_text_without_a_type_keyword(manager):
name = {'id':'name', 'text':'My name is Rowan Example'}
assert manager.get_relevant_memories('Rowan', [name]) == [name]
def test_empty_queries_and_limits_keep_their_contract(manager):
memories = [{'text':'tea preference'}, {'text':'tea recipe'}]
assert manager.get_relevant_memories(' ', memories) == []
assert manager.get_relevant_memories('tea', []) == []
assert len(manager.get_relevant_memories('tea', memories, max_items=1)) == 1
+126
View File
@@ -0,0 +1,126 @@
"""Real ESM/DOM regressions for Settings and Theme close/restore races."""
import json
import subprocess
import pytest
from tests.helpers.stylesheets import stylesheet_link_tags
@pytest.mark.parametrize('modal_id', ['settings-modal', 'theme-modal'])
@pytest.mark.parametrize('width', [1440, 390])
def test_modal_reopen_after_dock_close_and_pending_animation(modal_id, width):
script = r"""
import { chromium } from 'playwright';
const browser = await chromium.launch({headless:true});
try {
const page = await browser.newPage({viewport:{width:WIDTH,height:1000}});
await page.route('**/api/**', route => route.fulfill({json:{value:null}}));
await page.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN+'/static/js/storage.js');
await page.setContent('__STYLES__<div id="sidebar"></div>');
const result = await page.evaluate(async id => {
const html = await (await fetch('/static/index.html')).text();
const source = new DOMParser().parseFromString(html,'text/html');
document.body.append(source.getElementById(id));
const modal = document.getElementById(id);
const content = modal.querySelector('.modal-content');
const manager = await import('/static/js/modalManager.js');
let open, close;
if(id === 'settings-modal') {
const lifecycle = await import('/static/js/settings/lifecycle.js');
open = () => lifecycle.showSettingsModal(modal);
close = () => lifecycle.hideSettingsModal(modal);
} else {
const theme = await import('/static/js/theme.js');
// The old sidebar simply removed hidden; the public opener now
// also cancels a pending close and restores manager state.
open = theme.openPopup || (() => modal.classList.remove('hidden'));
close = () => theme.closePopup();
}
const visible = () => getComputedStyle(modal).display !== 'none';
const wait = () => new Promise(resolve=>setTimeout(resolve,350));
const states = {};
if (id === 'theme-modal') {
const theme = await import('/static/js/theme.js');
theme.togglePopup();
theme.togglePopup();
theme.togglePopup();
await wait();
states.fastToggleReopens = visible();
close();
await wait();
}
open();
manager.register(id,{closeFn:close,restoreFn:()=>{}});
manager.minimize(id);
manager.close(id);
await wait();
open();
await wait();
states.afterDockClose = visible();
// An opening/descendant animation must not reach an orphan closer.
content.dispatchEvent(new AnimationEvent('animationend',{bubbles:true,animationName:'modal-enter'}));
states.afterOpeningAnimation = visible();
// Begin a visible close, then reopen before its fallback expires.
close();
open();
await wait();
states.afterFastReopen = visible();
manager.register(id,{closeFn:close,restoreFn:()=>{}});
manager.minimize(id);
open();
states.directOpenRestoresMinimized = visible() && !manager.isMinimized(id);
// A child animation must not terminate a normal parent close.
close();
content.querySelector('button').dispatchEvent(new AnimationEvent('animationend',{bubbles:true}));
states.childAnimationDoesNotClose = !modal.classList.contains('hidden');
content.dispatchEvent(new AnimationEvent('animationend',{bubbles:true,animationName:'modal-exit'}));
states.normalCloseCompletes = modal.classList.contains('hidden') && !content.classList.contains('modal-closing');
// Completing by animation must also remove the timer.
open();
await wait();
states.reopenAfterCompletedAnimation = visible();
close();
await wait();
states.fallbackCompletes = modal.classList.contains('hidden') && !content.classList.contains('modal-closing');
return states;
}, MODAL_ID);
console.log(JSON.stringify(result));
} finally { await browser.close(); }
"""
script = script.replace('WIDTH', str(width)).replace('MODAL_ID', json.dumps(modal_id))
script = script.replace('__STYLES__', stylesheet_link_tags())
result = subprocess.run(['node','--input-type=module','-e',script], capture_output=True, text=True, timeout=60)
assert result.returncode == 0, result.stdout + result.stderr
states = json.loads(result.stdout.strip())
assert all(states.values()), states
def test_modal_visibility_is_available_offline_before_first_open():
script = r"""
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
const browser = await chromium.launch({headless:true});
try {
const context = await browser.newContext({serviceWorkers:'allow'});
const page = await context.newPage();
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
await page.goto(origin+'/static/js/storage.js');
await page.evaluate(async () => {
await navigator.serviceWorker.register('/static/sw.js');
await navigator.serviceWorker.ready;
if(!navigator.serviceWorker.controller) await new Promise(resolve =>
navigator.serviceWorker.addEventListener('controllerchange',resolve,{once:true}));
const response = await caches.match('/static/js/modalVisibility.js');
if(!response?.ok) throw new Error('Modal helper is missing from install-time precache');
});
await context.setOffline(true);
const offline = await context.newPage();
await offline.goto(origin+'/static/js/storage.js');
const exports = await offline.evaluate(async () =>
Object.keys(await import('/static/js/modalVisibility.js')).sort());
assert.deepEqual(exports,['hideModal','showModal']);
} finally { await browser.close(); }
"""
result = subprocess.run(['node','--input-type=module','-e',script], capture_output=True, text=True, timeout=90)
assert result.returncode == 0, result.stdout + result.stderr
@@ -0,0 +1,81 @@
"""An automatic fallback must not prevent resolving the configured default."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.helpers.js_modules import js_function_source
ROOT = Path(__file__).resolve().parents[1]
PICKER = (ROOT / 'static/js/modelPicker.js').read_text()
@pytest.mark.skipif(not shutil.which('node'), reason='node not installed')
@pytest.mark.parametrize('case', ['fallback', 'manual', 'default', 'session', 'manual-during-fetch', 'no-default'])
def test_configured_default_and_selection_ownership(case):
source = '\n'.join(js_function_source(name, PICKER) for name in [
'_ensureDefaultPendingChat', '_ensureModelCacheForFallback', '_firstAvailableModel',
])
script = r'''
const source = JSON.parse(process.argv[1]), mode = process.argv[2];
let pending = {url:'http://models/v1/chat/completions',modelId:'first',endpointId:'shared',source:'fallback'};
if (mode === 'manual' || mode === 'default') pending.source = mode;
const initial = {...pending};
const configured = {endpoint_url:pending.url,model:'preferred',endpoint_id:'shared'};
let fetches = 0, updates = 0;
const _deps = {getCurrentSessionId:()=> mode === 'session' ? 'existing' : null,
getPendingChat:()=>pending,setPendingChat:value=>{pending=value;}};
const window = {modelsModule:{getCachedItems:()=>[{url:initial.url,models:['first','preferred'],endpoint_id:'shared'}]}};
const localStorage = {setItem(){}};
const API_BASE = 'http://app';
let _defaultChatPickInFlight = false, _defaultPendingSeq = 0;
function updateModelPicker(){updates++;}
async function fetch(){fetches++; if(mode==='manual-during-fetch') pending={...initial,modelId:'chosen',source:'manual'};
return {ok:true,json:async()=>mode==='no-default'?{}:configured};}
// All helpers share the same lexical dependencies as the production function.
eval(source.replace('async function _ensureDefaultPendingChat','globalThis.ensureDefault = async function'));
await globalThis.ensureDefault();
console.log(JSON.stringify({pending,fetches,updates,inFlight:_defaultChatPickInFlight}));
'''
# Script is a module so async fetch ordering is exercised without a browser shim.
result = subprocess.run(['node', '--input-type=module', '-e', script, json.dumps(source), case],
cwd=ROOT, capture_output=True, text=True, check=True, timeout=15)
data = json.loads(result.stdout)
assert data['inFlight'] is False
if case == 'fallback':
assert data['pending']['modelId'] == 'preferred'
assert data['pending']['source'] == 'default'
assert data['fetches'] == 1
elif case == 'manual-during-fetch':
assert data['pending']['modelId'] == 'chosen'
assert data['pending']['source'] == 'manual'
else:
assert data['pending']['modelId'] == 'first'
assert data['pending']['source'] == (case if case in {'manual', 'default'} else 'fallback')
assert data['fetches'] == (1 if case == 'no-default' else 0)
@pytest.mark.skipif(not shutil.which('node'), reason='node not installed')
def test_cached_default_updates_visible_picker_after_fallback():
names = ['_ensureDefaultPendingChat', '_ensureModelCacheForFallback', '_firstAvailableModel', '_modelExists', 'updateModelPicker']
source = '\n'.join(js_function_source(name, PICKER).removeprefix('export ') for name in names)
script = r'''
let pending={url:'http://models/v1/chat/completions',modelId:'first',endpointId:'shared',source:'fallback'};
const _deps={getCurrentSessionId:()=>null,getSessions:()=>[],getPendingChat:()=>pending,setPendingChat:value=>{pending=value;}};
const label={textContent:''};
const document={getElementById:id=>id==='model-picker-label'?label:null};
const window={__odysseusDefaultChat:{endpoint_url:pending.url,endpoint_id:'shared',model:'preferred'},
modelsModule:{getCachedItems:()=>[{url:pending.url,endpoint_id:'shared',models:['first','preferred']}]}};
const localStorage={setItem(){}};
let _defaultChatPickInFlight=false,_defaultPendingSeq=0,_autoSelectingDefault=false;
function providerLogo(){return null;}function providerLabel(){return 'Fixture';}function _updateReasoningEffortUI(){}
eval(JSON.parse(process.argv[1])+'\nupdateModelPicker();');
console.log(JSON.stringify({pending,label:label.textContent}));
'''
result = subprocess.run(['node', '--input-type=module', '-e', script, json.dumps(source)],
cwd=ROOT, capture_output=True, text=True, check=True, timeout=15)
data = json.loads(result.stdout)
assert data['pending']['modelId'] == 'preferred'
assert 'preferred' in data['label']
@@ -0,0 +1,103 @@
"""History controllers must not dismiss reports the server failed to delete."""
import json
from pathlib import Path
import shutil
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[1]
def _run(outcomes, *, bulk=False, clear=False, statuses=None):
if not shutil.which("node"):
pytest.skip("node is required to execute research controllers")
script = r"""
const fs = require('fs'), vm = require('vm');
const options = JSON.parse(process.argv[1]);
const saved = new Map();
const errors = [], calls = [];
const context = {
localStorage: {getItem:k=>saved.get(k)||null, setItem:(k,v)=>saved.set(k,v)},
document: {getElementById:()=>null},
uiModule: {showError:message=>errors.push(message)},
window: {styledConfirm:async()=>true},
fetch: async (url, opts) => {
const id = url.split('/').pop();calls.push({id, method:opts.method, credentials:opts.credentials});
const outcome = options.outcomes[id] ?? 200;
if (outcome === 'network') throw new Error('scripted network failure');
return {ok:outcome>=200 && outcome<300, status:outcome};
},
};
vm.createContext(context);
const jobs = fs.readFileSync('static/js/research/jobs.js','utf8');
vm.runInContext(jobs.replace(/export /g, ''), context);
vm.runInContext('globalThis.jobs = {getJobs,removeJob};', context);
context.seed = Object.entries(options.statuses || {a:'done',b:'done',untouched:'done'}).map(([id,status])=>({id,status}));
vm.runInContext('getJobs().push(...seed);', context);
const panel = fs.readFileSync('static/js/research/panel.js','utf8');
const start = panel.indexOf('function _updateHistoryBulkBar(');
const end = panel.indexOf('function _renderJobs(', start);
if (start < 0 || end < 0) throw new Error('history controller boundaries missing');
vm.runInContext("_apiBase=''; let _historySelectMode=true; let _historySelectedIds=new Set(['a','b']); let _visibleHistoryIds=['a','b']; function _renderJobs(){};"+panel.slice(start,end), context);
(async()=>{
const command = options.clear ? '_clearHistory()' : options.bulk ? '_bulkDeleteHistory()' : "_deleteHistoryJobs(['a','b'])";
await vm.runInContext(command, context);
console.log(JSON.stringify({
ids:vm.runInContext('getJobs().map(j=>j.id)',context),
selected:vm.runInContext('[..._historySelectedIds]',context),
selectionMode:vm.runInContext('_historySelectMode',context),
dismissed:JSON.parse(saved.get('odysseus-research-dismissed')||'[]'),
errors,calls,
}));
})().catch(e=>{console.error(e);process.exitCode=1});
"""
args = dict(outcomes=outcomes, bulk=bulk, clear=clear, statuses=statuses)
result = subprocess.run(["node", "-e", script, json.dumps(args)], cwd=ROOT,
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
@pytest.mark.parametrize("failure", [403, 404, 500, 503, "network"])
def test_failed_reports_remain_visible_and_undismissed(failure):
result = _run({"a": failure, "b": failure})
assert result["ids"] == ["a", "b", "untouched"]
assert result["dismissed"] == []
assert len(result["errors"]) == 1
assert "2" in result["errors"][0]
assert all(call["method"] == "DELETE" and call["credentials"] == "same-origin"
for call in result["calls"])
@pytest.mark.parametrize("failure", [500, "network"])
def test_mixed_bulk_deletion_keeps_only_failed_selection_for_retry(failure):
result = _run({"a": 200, "b": failure}, bulk=True)
assert result["ids"] == ["b", "untouched"]
assert result["dismissed"] == ["a"]
assert result["selectionMode"] is True
assert result["selected"] == ["b"]
assert len(result["errors"]) == 1
def test_successful_bulk_deletion_exits_selection():
result = _run({"a": 200, "b": 200}, bulk=True)
assert result["ids"] == ["untouched"]
assert result["selectionMode"] is False
assert result["selected"] == []
assert result["dismissed"] == ["a", "b"]
assert result["errors"] == []
def test_local_failed_job_removal_needs_no_server_request():
result = _run({"a": 500}, statuses={"a": "error", "b": "cancelled"})
assert result["ids"] == []
assert result["calls"] == []
assert result["errors"] == []
def test_clear_history_preserves_running_jobs_and_failed_deletions():
result = _run({"a": 500}, clear=True, statuses={"a": "done", "b": "running"})
assert result["ids"] == ["a", "b"]
assert result["dismissed"] == []
assert [call["id"] for call in result["calls"]] == ["a"]
+42
View File
@@ -0,0 +1,42 @@
"""set_privileges rejects mistyped values instead of storing them (issue #6829)."""
import pytest
from tests.test_set_admin import _fresh_auth_manager
@pytest.mark.parametrize("privs", [
{"can_use_bash": "no"},
{"can_use_bash": 1},
{"max_messages_per_day": "abc"},
{"max_messages_per_day": True},
{"allowed_models": "gpt"},
{"block_all_models": None},
])
def test_set_privileges_rejects_mistyped_values(tmp_path, privs):
_, mgr = _fresh_auth_manager(tmp_path)
mgr.create_user("bob", "pw-123456")
before = dict(mgr.get_privileges("bob"))
with pytest.raises(ValueError):
mgr.set_privileges("bob", privs)
assert mgr.get_privileges("bob") == before
def test_set_privileges_accepts_well_typed_values(tmp_path):
_, mgr = _fresh_auth_manager(tmp_path)
mgr.create_user("bob", "pw-123456")
assert mgr.set_privileges("bob", {
"can_use_bash": True,
"max_messages_per_day": 25,
"allowed_models": ["gpt"],
"unknown_key": "ignored",
}) is True
privs = mgr.get_privileges("bob")
assert privs["can_use_bash"] is True
assert privs["max_messages_per_day"] == 25
assert privs["allowed_models"] == ["gpt"]
assert "unknown_key" not in privs
+112
View File
@@ -0,0 +1,112 @@
"""The real Skills module must leave test results before opening its editor."""
import json
import os
from pathlib import Path
import shutil
import subprocess
from bs4 import BeautifulSoup
import pytest
@pytest.mark.parametrize("width", [1440, 390])
@pytest.mark.parametrize("mode", ["done", "running", "source"])
def test_skill_editor_transitions(tmp_path, width, mode):
if not shutil.which("node"):
pytest.skip("node not installed")
repo = Path(__file__).resolve().parents[1]
index = BeautifulSoup((repo / "static/index.html").read_text(), "html.parser")
modal = index.select_one("#memory-modal")
modal["class"] = [value for value in modal.get("class", []) if value != "hidden"]
for panel in modal.select("[data-memory-panel]"):
classes = set(panel.get("class", []))
if panel["data-memory-panel"] == "skills":
classes.discard("hidden")
else:
classes.add("hidden")
panel["class"] = sorted(classes)
styles = "".join(str(node) for node in index.select('link[rel="stylesheet"]'))
document = tmp_path / "skills.html"
document.write_text(styles + str(modal) + str(index.select_one("#toast")), encoding="utf-8")
script = r"""
const fs=require('node:fs'), assert=require('node:assert/strict');
let chromium;
try { ({chromium}=require('playwright')); }
catch(e) { if(e.code==='MODULE_NOT_FOUND')process.exit(77); throw e; }
(async()=>{
const browser=await chromium.launch({headless:true});
try{
const origin=process.env.ODYSSEUS_TEST_STATIC_ORIGIN, mode=process.argv[3];
const page=await browser.newPage({viewport:{width:Number(process.argv[2]),height:900}});
page.setDefaultTimeout(4000);
const source='---\nname: fixture-editor\nversion: "1.0"\n---\n\n## Procedure\nOriginal source';
let markdown=source,posts=0,statusCalls=0;
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route(origin+'/api/**',async route=>{
const request=route.request(),path=new URL(request.url()).pathname;
let data={};
if(path==='/api/skills')data={skills:[{name:'fixture-editor',description:'Editor fixture',status:'draft',source:'user',confidence:0.9,procedure:['Original source']}]};
else if(path.endsWith('/markdown')){
if(request.method()==='POST'){posts++;markdown=request.postDataJSON().markdown;data={ok:true};}
else data={markdown};
}else if(path.endsWith('/test-status')){
statusCalls++;
const status=mode==='running'&&statusCalls===1?'running':'done';
data={status,log:[{type:'say',text:'Completed fixture output'}],verdict:status==='done'?{verdict:'pass',confidence:0.95,summary:'Fixture verdict',issues:[]}:null};
}else if(path.endsWith('/audit-all/status'))data={status:'none'};
await route.fulfill({contentType:'application/json',body:JSON.stringify(data)});
});
await page.goto(origin+'/static/js/storage.js');
await page.setContent(fs.readFileSync(process.argv[1],'utf8'));
await page.evaluate(async()=>{const skills=await import('/static/js/skills.js');await skills.loadSkills();});
const card=page.locator('.skill-card[data-skill-name="fixture-editor"]');
await card.locator('.skill-card-name').click();
if(mode!=='source'){
await card.getByRole('button',{name:'Test',exact:true}).click();
if(mode==='running'){
await card.getByText('…running (you can close this — it keeps going)',{exact:true}).waitFor();
await card.locator('.skill-chevron-up').click();
await card.locator('.skill-kebab-btn').click();
await page.locator('.skill-kebab-menu:visible').getByRole('button',{name:'Edit',exact:true}).click();
}else{
await card.getByText('Fixture verdict',{exact:true}).waitFor();
await card.locator('[data-act="edit"]').click();
}
}else await card.getByRole('button',{name:'Edit',exact:true}).click();
const editor=card.locator('.skill-md-editor');await editor.waitFor({state:'visible'});
assert.equal(await editor.inputValue(),source);
assert.equal(await card.locator('.skill-test').count(),0);
assert(await card.locator('.doclib-card-expanded-actions button:visible').evaluateAll(buttons=>buttons.every(button=>{
const bounds=button.getBoundingClientRect(),card=button.closest('.skill-card').getBoundingClientRect();
return bounds.left>=card.left&&bounds.right<=card.right;
})), 'Editor actions must fit inside the card');
await editor.fill(source+'\nCancelled draft');
if(mode==='running'){
await page.waitForFunction(()=>document.querySelector('.skill-card')?._testPoll===null);
assert.equal(await editor.inputValue(),source+'\nCancelled draft');
}
await card.getByRole('button',{name:'Cancel',exact:true}).click();
assert.equal(await card.locator('.skill-md-editor').count(),0);
assert.equal(posts,0);assert.equal(markdown,source);
assert.equal(await card.locator('.skill-md-pre').innerText(),source);
if(mode!=='source'){
await card.getByRole('button',{name:'Test',exact:true}).click();
await card.getByText('Fixture verdict',{exact:true}).waitFor();
await card.locator('[data-act="edit"]').click();
}else await card.getByRole('button',{name:'Edit',exact:true}).click();
await editor.fill(source+'\nSaved change');
await card.getByRole('button',{name:'Save',exact:true}).click();
await page.waitForFunction(()=>!document.querySelector('.skill-md-editor'));
assert.equal(posts,1);assert.equal(markdown,source+'\nSaved change');
assert.deepEqual(errors,[]);
console.log(JSON.stringify({mode,width:process.argv[2],posts}));
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
"""
result = subprocess.run(["node", "-e", script, str(document), str(width), mode],
cwd=repo, env=os.environ.copy(), capture_output=True,
text=True, timeout=45)
if result.returncode == 77:
pytest.skip("playwright not installed")
assert result.returncode == 0, result.stdout + result.stderr
assert json.loads(result.stdout)["posts"] == 1
+76
View File
@@ -0,0 +1,76 @@
"""Research reports preserve TeX for the shared, vendored math renderer."""
from bs4 import BeautifulSoup
import pytest
from src.visual_report import _md_to_html, generate_visual_report
@pytest.mark.parametrize("source,tex,display", [
(r"Value $x_i^2$.", "x_i^2", "false"),
(r"Value \(x_i^2\).", "x_i^2", "false"),
(r"Value $$\sum_i x_i$$.", r"\sum_i x_i", "true"),
(r"Value \[\sum_i x_i\].", r"\sum_i x_i", "true"),
("$$a_i\n\nb_i$$", "a_i\n\nb_i", "true"),
("\\[a_i\n\nb_i\\]", "a_i\n\nb_i", "true"),
])
def test_math_source_survives_markdown(source, tex, display):
soup = BeautifulSoup(_md_to_html(source), "html.parser")
pending = soup.select(".ody-math-pending")
assert len(pending) == 1
assert pending[0].get_text() == tex
assert pending[0]["data-display"] == display
assert pending[0].find("em") is None
@pytest.mark.parametrize("source", [
"`$x_i$`", "```text\n$x_i$\n```", "~~~text\n$x_i$\n~~~",
" $x_i$", "<code>$x_i$</code>", "<pre>$x_i$</pre>",
"Prices range from $5 to $10.", "An escaped \\$5 costs less than $10.",
])
def test_code_and_currency_are_not_math(source):
soup = BeautifulSoup(_md_to_html(source), "html.parser")
assert not soup.select(".ody-math-pending")
assert "$" in soup.get_text()
def test_html_attributes_are_not_rewritten_as_math():
soup = BeautifulSoup(_md_to_html('<a title="$literal$">Value $x_i$</a>'), "html.parser")
assert soup.a["title"] == "$literal$"
assert soup.select_one("a .ody-math-pending").get_text() == "x_i"
def test_link_destinations_stay_literal_while_link_labels_can_contain_math():
soup = BeautifulSoup(_md_to_html('[$x_i$](https://example.com/$literal$)'), "html.parser")
assert soup.a["href"] == "https://example.com/$literal$"
assert soup.select_one("a .ody-math-pending").get_text() == "x_i"
def test_math_source_is_inert_and_report_sanitizer_stays_active():
soup = BeautifulSoup(_md_to_html(
r'$\text{<img src=x onerror="alert(1)">}$'
'\n\n<script>alert(2)</script><a href="javascript:alert(3)">link</a>'
), "html.parser")
assert soup.select_one(".ody-math-pending").get_text() == r'\text{<img src=x onerror="alert(1)">}'
assert soup.find("img") is None
assert soup.find("script") is None
assert not soup.select('a[href^="javascript:"]')
def test_heading_math_reaches_body_and_toc_without_changing_anchor_contract():
soup = BeautifulSoup(generate_visual_report(
"Formula report", "# Formula report\n\n## Formula $x_i$\n\nValue $x_i$.",
session_id="rp-math",
), "html.parser")
link = soup.select_one(".toc-sidebar nav a")
heading = soup.find(id=link["href"][1:])
assert heading["id"] == "formula-xi"
for node in (link, heading):
assert node.select_one(".ody-math-pending").get_text() == "x_i"
assert soup.select_one("#mobile-section-select option").get_text() == "Formula x_i"
def test_unclosed_display_math_stays_readable():
soup = BeautifulSoup(_md_to_html("$$unfinished\n\nA later paragraph."), "html.parser")
assert not soup.select(".ody-math-pending")
assert "unfinished" in soup.get_text()
assert "A later paragraph." in soup.get_text()
+84
View File
@@ -0,0 +1,84 @@
"""Exercise generated report HTML with the real vendored KaTeX in Chromium."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import pytest
from src.visual_report import generate_visual_report
@pytest.mark.parametrize("width", [1440, 390])
@pytest.mark.parametrize("mode", ["math", "plain", "blocked"])
def test_report_math_in_browser(tmp_path, width, mode):
if not shutil.which("node"):
pytest.skip("node not installed")
markdown = (
"# Math report\n\n" + r"## Formula $x_i$" + "\n\n"
+ r"Inline \(x_i^2\) and $a < b$." + "\n\n"
+ r"\[\sum_{i=1}^{3} x_i\]" + "\n\n"
+ r"Invalid: $\frac{1}$. Unsafe: $\href{javascript:alert(1)}{bad}$."
+ "\n\n`$literal$` costs $5 to $10."
+ "\n\n$$" + "+".join(["x_i"] * 30) + "$$"
) if mode != "plain" else "## Findings\n\nNo math here."
document = tmp_path / "report.html"
document.write_text(generate_visual_report("Math report", markdown), encoding="utf-8")
script = r"""
const fs = require('node:fs'), assert = require('node:assert/strict');
let chromium;
try { ({chromium} = require('playwright')); }
catch (error) { if (error.code === 'MODULE_NOT_FOUND') process.exit(77); throw error; }
(async () => {
const browser = await chromium.launch({headless:true});
try {
const page = await browser.newPage({viewport:{width:Number(process.argv[2]),height:900}});
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
const errors = [], requests = [], failures = [];
page.on('pageerror', e => errors.push(e.message));
page.on('request', r => requests.push(r.url()));
page.on('requestfailed', r => failures.push(r.url()));
page.on('dialog', async d => { errors.push('Unexpected dialog'); await d.dismiss(); });
const mode = process.argv[3];
if (mode === 'blocked') await page.route('**/static/lib/katex/katex.min.js', r => r.abort());
await page.route(origin + '/report-math-test', r => r.fulfill({
contentType:'text/html', body:fs.readFileSync(process.argv[1], 'utf8')
}));
await page.goto(origin + '/report-math-test');
if (mode === 'math') {
await page.locator('.content .katex-display').first().waitFor({state:'attached'});
await page.waitForFunction(() => !document.querySelector('.ody-math-pending'));
assert.equal(await page.locator('.toc-sidebar .katex').count(), 1);
assert.equal(await page.locator('.content .katex-error').count(), 1);
assert.equal(await page.locator('.content code').innerText(), '$literal$');
assert((await page.locator('.content').innerText()).includes('$5 to $10'));
assert.equal(await page.locator('a[href^="javascript:"]').count(), 0);
assert.equal(requests.filter(u => u.endsWith('/static/lib/katex/katex.min.js')).length, 1);
assert.equal(requests.filter(u => u.endsWith('/static/lib/katex/katex.min.css')).length, 1);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
assert.deepEqual(failures, []);
} else if (mode === 'blocked') {
await page.waitForFunction(() => performance.getEntriesByType('resource').some(e => e.name.includes('katex.min.js')));
assert((await page.locator('.content .ody-math-pending').first().innerText()).includes('x_i'));
assert.equal(await page.locator('.katex').count(), 0);
} else {
await page.waitForLoadState('networkidle');
assert.equal(requests.some(u => u.includes('/static/lib/katex/')), false);
assert.equal(requests.some(u => u.includes('/static/js/markdown.js')), false);
}
assert.deepEqual(errors, []);
assert(requests.every(u => u.startsWith(origin + '/')), JSON.stringify(requests));
console.log(JSON.stringify({mode, width:process.argv[2], requests:requests.length}));
} finally { await browser.close(); }
})().catch(e => { console.error(e); process.exitCode=1; });
"""
result = subprocess.run(
["node", "-e", script, str(document), str(width), mode],
cwd=Path(__file__).resolve().parents[1], env=os.environ.copy(),
capture_output=True, text=True, timeout=60,
)
if result.returncode == 77:
pytest.skip("playwright not installed")
assert result.returncode == 0, result.stdout + result.stderr
assert json.loads(result.stdout)["mode"] == mode