/cmdline; stdin also avoids leaving the secret in
+ # the child process environment.
+ stdout, stderr, rc = await _run_bw(
+ ["unlock", "--raw"],
+ input_text=req.master_password + "\n",
+ )
+ if rc != 0:
+ return {"ok": False, "error": f"Unlock failed: {stderr[:300]}"}
+ session = stdout.strip()
+ if not session:
+ return {"ok": False, "error": "bw returned empty session"}
+ cfg = _load_config()
+ cfg["session"] = session
+ cfg["unlocked_at"] = datetime.utcnow().isoformat()
+ _save_config(cfg)
+ return {"ok": True, "message": "Vault unlocked"}
+
+ @router.post("/lock")
+ async def lock(request: Request):
+ """Lock the vault (clear session from config)."""
+ require_admin(request)
+ cfg = _load_config()
+ cfg.pop("session", None)
+ cfg.pop("unlocked_at", None)
+ _save_config(cfg)
+ # Also tell bw to lock
+ await _run_bw(["lock"])
+ return {"ok": True, "message": "Vault locked"}
+
+ @router.post("/logout")
+ async def logout(request: Request):
+ """Log out of the Bitwarden CLI completely."""
+ require_admin(request)
+ await _run_bw(["logout"])
+ cfg = _load_config()
+ cfg.pop("session", None)
+ cfg.pop("email", None)
+ cfg.pop("unlocked_at", None)
+ _save_config(cfg)
+ return {"ok": True}
+
+ return router
+
+
+async def _check_bw_installed() -> bool:
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ _find_bw(), "--version",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ await proc.communicate()
+ return proc.returncode == 0
+ except Exception:
+ return False
diff --git a/routes/webhook/__init__.py b/routes/webhook/__init__.py
deleted file mode 100644
index e51389e3a..000000000
--- a/routes/webhook/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""Webhook route domain package (slice 2l, #4082/#4071).
-
-Contains webhook_routes.py, migrated from the flat routes/ directory.
-Backward-compat shim at routes/webhook_routes.py re-exports from here.
-"""
diff --git a/routes/webhook/webhook_routes.py b/routes/webhook/webhook_routes.py
deleted file mode 100644
index 8d3a704c6..000000000
--- a/routes/webhook/webhook_routes.py
+++ /dev/null
@@ -1,395 +0,0 @@
-"""Webhook, API Token, and sync chat routes."""
-
-import uuid
-import logging
-from typing import Optional
-
-import httpx
-from fastapi import APIRouter, HTTPException, Request, Form
-from pydantic import BaseModel, Field
-
-from core.database import SessionLocal, Webhook, ModelEndpoint
-from src.auth_helpers import owner_filter
-from src.url_security import validate_public_http_url
-from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter(prefix="/api", tags=["webhooks"])
-
-# Input limits
-MAX_NAME_LEN = 100
-MAX_URL_LEN = 2048
-MAX_SECRET_LEN = 256
-MAX_MESSAGE_LEN = 32_000
-
-
-from core.middleware import require_admin as _require_admin
-
-
-def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
- """First enabled ModelEndpoint visible to token_owner — their own rows plus
- legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
- let a chat-scoped token fall back onto another user's private endpoint and
- silently spend that owner's API key/quota. Prefer owner rows before shared
- rows. Fails closed to null-owner rows only when token_owner is absent.
- Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
- """
- query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
- if token_owner:
- query = owner_filter(query, ModelEndpoint, token_owner)
- return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
- return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
-
-
-def _caller_owns_session(sess_owner, caller) -> bool:
- """Strict session-ownership gate for the token-authenticated sync-chat
- endpoint (`POST /api/v1/chat`).
-
- Mirrors ``_verify_session_owner`` in session_routes.py and the null-owner
- gates in notes/calendar/gallery: a caller may resume a session ONLY when
- its owner matches them exactly. A null/empty session owner (legacy or
- migrated rows) is deliberately NOT resumable by an arbitrary token — the
- old ``sess_owner and sess_owner != caller`` form skipped the check whenever
- ``sess_owner`` was falsy, so any chat-scoped token (e.g. a paired mobile
- device) could resume such a session, inject a message, and read back its
- history and reuse the owner's endpoint credentials. Fail closed: an
- unresolvable caller also returns False.
- """
- if not caller:
- return False
- return sess_owner == caller
-
-
-def setup_webhook_routes(
- webhook_manager: WebhookManager,
- auth_manager,
- session_manager=None,
- api_key_manager=None,
-) -> APIRouter:
-
- @router.get("/webhooks")
- def list_webhooks(request: Request):
- _require_admin(request)
- db = SessionLocal()
- try:
- hooks = db.query(Webhook).all()
- return [
- {
- "id": w.id,
- "name": w.name,
- "url": w.url,
- "has_secret": bool(w.secret),
- "events": w.events.split(",") if w.events else [],
- "is_active": w.is_active,
- "last_triggered_at": w.last_triggered_at.isoformat() if w.last_triggered_at else None,
- "last_status_code": w.last_status_code,
- "last_error": w.last_error,
- "created_at": w.created_at.isoformat() if w.created_at else None,
- }
- for w in hooks
- ]
- finally:
- db.close()
-
- @router.post("/webhooks")
- def create_webhook(
- request: Request,
- name: str = Form(""),
- url: str = Form(""),
- secret: str = Form(""),
- events: str = Form(""),
- ):
- _require_admin(request)
- name = name.strip()[:MAX_NAME_LEN]
- if not name:
- raise HTTPException(400, "Webhook name is required")
- try:
- url = validate_webhook_url(url)
- except ValueError as e:
- raise HTTPException(400, str(e))
- try:
- events = validate_events(events)
- except ValueError as e:
- raise HTTPException(400, str(e))
-
- secret_val = secret.strip()[:MAX_SECRET_LEN] or None
- # Encrypt the secret at rest using the same Fernet key as API keys
- encrypted_secret = None
- if secret_val and api_key_manager:
- encrypted_secret = api_key_manager.encrypt_api_key(secret_val)
- elif secret_val:
- encrypted_secret = secret_val # Fallback if no encryption available
-
- webhook_id = str(uuid.uuid4())[:8]
- db = SessionLocal()
- try:
- db.add(Webhook(
- id=webhook_id,
- name=name,
- url=url,
- secret=encrypted_secret,
- events=events,
- is_active=True,
- ))
- db.commit()
- finally:
- db.close()
-
- return {"id": webhook_id, "name": name}
-
- @router.post("/webhooks/{webhook_id}/test")
- async def test_webhook(request: Request, webhook_id: str):
- _require_admin(request)
- db = SessionLocal()
- try:
- wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
- if not wh:
- raise HTTPException(404, "Webhook not found")
- url, secret = wh.url, wh.secret
- finally:
- db.close()
-
- await webhook_manager.deliver_test(webhook_id, url, secret)
- return {"status": "sent"}
-
- @router.patch("/webhooks/{webhook_id}")
- def toggle_webhook(request: Request, webhook_id: str):
- _require_admin(request)
- db = SessionLocal()
- try:
- wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
- if not wh:
- raise HTTPException(404, "Webhook not found")
- wh.is_active = not wh.is_active
- db.commit()
- return {"id": webhook_id, "is_active": wh.is_active}
- finally:
- db.close()
-
- @router.delete("/webhooks/{webhook_id}")
- def delete_webhook(request: Request, webhook_id: str):
- _require_admin(request)
- db = SessionLocal()
- try:
- deleted = db.query(Webhook).filter(Webhook.id == webhook_id).delete()
- db.commit()
- if not deleted:
- raise HTTPException(404, "Webhook not found")
- finally:
- db.close()
- return {"status": "deleted"}
-
- # ================================================================
- # Sync Chat Endpoint (for n8n / Make / Activepieces)
- # ================================================================
-
- # Known provider base URLs — auto-resolved from api_key prefix or model name
- KNOWN_PROVIDERS = {
- "deepseek": "https://api.deepseek.com/v1",
- "openai": "https://api.openai.com/v1",
- "mistral": "https://api.mistral.ai/v1",
- "groq": "https://api.groq.com/openai/v1",
- "together": "https://api.together.xyz/v1",
- "openrouter": "https://openrouter.ai/api/v1",
- "ollama": "https://ollama.com/api",
- "opencode-zen": "https://opencode.ai/zen/v1",
- "opencode-go": "https://opencode.ai/zen/go/v1",
- "fireworks": "https://api.fireworks.ai/inference/v1",
- "venice": "https://api.venice.ai/api/v1",
- "kimi-code": "https://api.kimi.com/coding/v1",
- "kimicode": "https://api.kimi.com/coding/v1",
- }
-
- # Model prefix → provider mapping for auto-detection
- MODEL_PROVIDER_MAP = {
- "deepseek": "deepseek",
- "gpt-": "openai",
- "o1": "openai",
- "o3": "openai",
- "o4": "openai",
- "mistral": "mistral",
- "llama": "groq",
- "mixtral": "groq",
- "kimi-for-coding": "kimi-code",
- "kimi": "kimi-code",
- }
-
- def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
- """Try to auto-resolve a base URL from provider name or model prefix."""
- if provider and provider.lower() in KNOWN_PROVIDERS:
- return KNOWN_PROVIDERS[provider.lower()]
- if model:
- model_lower = model.lower()
- for prefix, prov in MODEL_PROVIDER_MAP.items():
- if model_lower.startswith(prefix):
- return KNOWN_PROVIDERS[prov]
- return None
-
- class SyncChatRequest(BaseModel):
- message: str = Field(..., max_length=MAX_MESSAGE_LEN)
- model: Optional[str] = Field(None, max_length=200)
- session: Optional[str] = Field(None, max_length=100)
- api_key: Optional[str] = Field(None, max_length=256)
- base_url: Optional[str] = Field(None, max_length=MAX_URL_LEN)
- provider: Optional[str] = Field(None, max_length=50)
-
- @router.post("/v1/chat")
- async def sync_chat(request: Request, body: SyncChatRequest):
- if not getattr(request.state, "api_token", False):
- raise HTTPException(403, "This endpoint requires an API token")
- scopes = set(getattr(request.state, "api_token_scopes", []) or [])
- if "chat" not in scopes:
- raise HTTPException(403, "API token is not scoped for chat")
- token_owner = getattr(request.state, "api_token_owner", None)
-
- from core.models import ChatMessage
- from src.llm_core import llm_call_async
- from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
-
- message = body.message.strip()
- if not message:
- raise HTTPException(400, "Message is required")
-
- session_id = body.session
- sess = None
-
- # --- Case 1: Resume an existing session ---
- if session_id and session_manager:
- try:
- sess = session_manager.get_session(session_id)
- except (KeyError, Exception):
- raise HTTPException(404, "Session not found")
- # SECURITY: verify the API-token's user owns this session — without
- # this any token holder could resume any user's chat by passing its
- # ID. The token's user is on request.state.user (set by API-token
- # middleware); fall back to require_user if not present.
- try:
- from src.auth_helpers import get_current_user as _gcu
- _tok_user = token_owner or getattr(request.state, "user", None) or _gcu(request)
- except Exception:
- _tok_user = None
- # Strict ownership (see _caller_owns_session): fail closed so a
- # null-owner / cross-owner session can't be resumed by an arbitrary
- # chat-scoped token.
- _sess_owner = getattr(sess, "owner", None)
- if not _caller_owns_session(_sess_owner, _tok_user):
- raise HTTPException(404, "Session not found")
-
- # --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
- if not sess and body.api_key:
- api_key = body.api_key.strip()
- model = body.model or "deepseek-chat"
-
- # Validate only token-supplied direct base_url; auto-resolved known-provider
- # URLs are not subject to extra local/LAN blocking beyond existing provider logic.
- direct_base_url = body.base_url.strip().rstrip("/") if body.base_url else None
- if direct_base_url:
- try:
- base_url = validate_public_http_url(direct_base_url)
- except ValueError as e:
- detail = str(e).replace("URL", "base_url", 1)
- raise HTTPException(400, detail)
- else:
- base_url = _resolve_base_url(model, body.provider)
- if not base_url:
- raise HTTPException(400,
- "Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
- "or provider ('deepseek', 'openai', 'groq', etc.)")
- base_url = normalize_base(base_url)
- endpoint_url = build_chat_url(base_url)
-
- if not session_manager:
- raise HTTPException(500, "Session manager not available")
-
- sid = str(uuid.uuid4())
- sess = session_manager.create_session(
- session_id=sid, name="API Chat", endpoint_url=endpoint_url,
- model=model, owner=token_owner,
- )
- sess.headers = build_headers(api_key, base_url)
- session_manager.save_sessions()
- session_id = sid
-
- # --- Case 3: Fall back to first configured ModelEndpoint ---
- if not sess:
- db = SessionLocal()
- try:
- ep = _select_api_chat_fallback_endpoint(db, token_owner)
- finally:
- db.close()
-
- if not ep:
- raise HTTPException(400,
- "No session, api_key, or configured endpoints. "
- "Pass api_key + model, or configure an endpoint in Admin.")
-
- base_url = normalize_base(ep.base_url)
- endpoint_url = build_chat_url(base_url)
- model = body.model or "auto"
- api_key = ep.api_key
- if getattr(ep, "provider_auth_id", None):
- try:
- from src.endpoint_resolver import resolve_endpoint_runtime
- base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
- endpoint_url = build_chat_url(base_url)
- except Exception:
- raise HTTPException(500, "Could not resolve endpoint credentials")
-
- if model == "auto":
- try:
- async with httpx.AsyncClient(timeout=5) as client:
- models_url = build_models_url(base_url)
- hdrs = build_headers(api_key, base_url)
- if models_url:
- resp = await client.get(models_url, headers=hdrs)
- resp.raise_for_status()
- data = resp.json()
- items = data if isinstance(data, list) else (data.get("data") or [])
- ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
- if not ids and isinstance(data, dict):
- ids = [
- m.get("name") or m.get("model")
- for m in (data.get("models") or [])
- if m.get("name") or m.get("model")
- ]
- else:
- import json as _json
- ids = _json.loads(ep.cached_models or "[]")
- model = ids[0] if ids else "auto"
- except Exception:
- raise HTTPException(500, "Could not discover models from endpoint")
-
- if not session_manager:
- raise HTTPException(500, "Session manager not available")
-
- sid = str(uuid.uuid4())
- sess = session_manager.create_session(
- session_id=sid, name="API Chat", endpoint_url=endpoint_url,
- model=model, owner=token_owner,
- )
- if api_key:
- sess.headers = build_headers(api_key, base_url)
- session_manager.save_sessions()
- session_id = sid
-
- # --- Send message and get response ---
- sess.add_message(ChatMessage("user", message))
-
- messages = [{"role": m.role, "content": m.content} for m in sess.history]
-
- reply = await llm_call_async(
- sess.endpoint_url, sess.model, messages,
- headers=sess.headers, timeout=120,
- )
- sess.add_message(ChatMessage("assistant", reply))
- session_manager.save_sessions()
-
- webhook_manager.fire_and_forget("chat.completed", {
- "session_id": session_id, "model": sess.model,
- "user_message": message[:2000], "response": reply[:2000],
- })
-
- return {"response": reply, "session_id": session_id, "model": sess.model}
-
- return router
diff --git a/routes/webhook_routes.py b/routes/webhook_routes.py
index 7c5e0453e..8d3a704c6 100644
--- a/routes/webhook_routes.py
+++ b/routes/webhook_routes.py
@@ -1,16 +1,395 @@
-"""Backward-compat shim — canonical location is routes/webhook/webhook_routes.py.
+"""Webhook, API Token, and sync chat routes."""
-This module is replaced in ``sys.modules`` by the canonical module object so
-that ``import routes.webhook_routes``, ``from routes.webhook_routes import X``,
-``importlib.import_module("routes.webhook_routes")``, and the
-``__import__("routes.webhook_routes", fromlist=[...])`` + ``setattr(wh_mod,
-...)`` pattern used by test_null_owner_gates.py all operate on the *same*
-object. Keeps existing import paths working after slice 2l (#4082/#4071).
-Source-introspection tests read the canonical file by path.
-"""
+import uuid
+import logging
+from typing import Optional
-import sys as _sys
+import httpx
+from fastapi import APIRouter, HTTPException, Request, Form
+from pydantic import BaseModel, Field
-from routes.webhook import webhook_routes as _canonical # noqa: F401
+from core.database import SessionLocal, Webhook, ModelEndpoint
+from src.auth_helpers import owner_filter
+from src.url_security import validate_public_http_url
+from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
-_sys.modules[__name__] = _canonical
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api", tags=["webhooks"])
+
+# Input limits
+MAX_NAME_LEN = 100
+MAX_URL_LEN = 2048
+MAX_SECRET_LEN = 256
+MAX_MESSAGE_LEN = 32_000
+
+
+from core.middleware import require_admin as _require_admin
+
+
+def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
+ """First enabled ModelEndpoint visible to token_owner — their own rows plus
+ legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
+ let a chat-scoped token fall back onto another user's private endpoint and
+ silently spend that owner's API key/quota. Prefer owner rows before shared
+ rows. Fails closed to null-owner rows only when token_owner is absent.
+ Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
+ """
+ query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
+ if token_owner:
+ query = owner_filter(query, ModelEndpoint, token_owner)
+ return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
+ return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
+
+
+def _caller_owns_session(sess_owner, caller) -> bool:
+ """Strict session-ownership gate for the token-authenticated sync-chat
+ endpoint (`POST /api/v1/chat`).
+
+ Mirrors ``_verify_session_owner`` in session_routes.py and the null-owner
+ gates in notes/calendar/gallery: a caller may resume a session ONLY when
+ its owner matches them exactly. A null/empty session owner (legacy or
+ migrated rows) is deliberately NOT resumable by an arbitrary token — the
+ old ``sess_owner and sess_owner != caller`` form skipped the check whenever
+ ``sess_owner`` was falsy, so any chat-scoped token (e.g. a paired mobile
+ device) could resume such a session, inject a message, and read back its
+ history and reuse the owner's endpoint credentials. Fail closed: an
+ unresolvable caller also returns False.
+ """
+ if not caller:
+ return False
+ return sess_owner == caller
+
+
+def setup_webhook_routes(
+ webhook_manager: WebhookManager,
+ auth_manager,
+ session_manager=None,
+ api_key_manager=None,
+) -> APIRouter:
+
+ @router.get("/webhooks")
+ def list_webhooks(request: Request):
+ _require_admin(request)
+ db = SessionLocal()
+ try:
+ hooks = db.query(Webhook).all()
+ return [
+ {
+ "id": w.id,
+ "name": w.name,
+ "url": w.url,
+ "has_secret": bool(w.secret),
+ "events": w.events.split(",") if w.events else [],
+ "is_active": w.is_active,
+ "last_triggered_at": w.last_triggered_at.isoformat() if w.last_triggered_at else None,
+ "last_status_code": w.last_status_code,
+ "last_error": w.last_error,
+ "created_at": w.created_at.isoformat() if w.created_at else None,
+ }
+ for w in hooks
+ ]
+ finally:
+ db.close()
+
+ @router.post("/webhooks")
+ def create_webhook(
+ request: Request,
+ name: str = Form(""),
+ url: str = Form(""),
+ secret: str = Form(""),
+ events: str = Form(""),
+ ):
+ _require_admin(request)
+ name = name.strip()[:MAX_NAME_LEN]
+ if not name:
+ raise HTTPException(400, "Webhook name is required")
+ try:
+ url = validate_webhook_url(url)
+ except ValueError as e:
+ raise HTTPException(400, str(e))
+ try:
+ events = validate_events(events)
+ except ValueError as e:
+ raise HTTPException(400, str(e))
+
+ secret_val = secret.strip()[:MAX_SECRET_LEN] or None
+ # Encrypt the secret at rest using the same Fernet key as API keys
+ encrypted_secret = None
+ if secret_val and api_key_manager:
+ encrypted_secret = api_key_manager.encrypt_api_key(secret_val)
+ elif secret_val:
+ encrypted_secret = secret_val # Fallback if no encryption available
+
+ webhook_id = str(uuid.uuid4())[:8]
+ db = SessionLocal()
+ try:
+ db.add(Webhook(
+ id=webhook_id,
+ name=name,
+ url=url,
+ secret=encrypted_secret,
+ events=events,
+ is_active=True,
+ ))
+ db.commit()
+ finally:
+ db.close()
+
+ return {"id": webhook_id, "name": name}
+
+ @router.post("/webhooks/{webhook_id}/test")
+ async def test_webhook(request: Request, webhook_id: str):
+ _require_admin(request)
+ db = SessionLocal()
+ try:
+ wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
+ if not wh:
+ raise HTTPException(404, "Webhook not found")
+ url, secret = wh.url, wh.secret
+ finally:
+ db.close()
+
+ await webhook_manager.deliver_test(webhook_id, url, secret)
+ return {"status": "sent"}
+
+ @router.patch("/webhooks/{webhook_id}")
+ def toggle_webhook(request: Request, webhook_id: str):
+ _require_admin(request)
+ db = SessionLocal()
+ try:
+ wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
+ if not wh:
+ raise HTTPException(404, "Webhook not found")
+ wh.is_active = not wh.is_active
+ db.commit()
+ return {"id": webhook_id, "is_active": wh.is_active}
+ finally:
+ db.close()
+
+ @router.delete("/webhooks/{webhook_id}")
+ def delete_webhook(request: Request, webhook_id: str):
+ _require_admin(request)
+ db = SessionLocal()
+ try:
+ deleted = db.query(Webhook).filter(Webhook.id == webhook_id).delete()
+ db.commit()
+ if not deleted:
+ raise HTTPException(404, "Webhook not found")
+ finally:
+ db.close()
+ return {"status": "deleted"}
+
+ # ================================================================
+ # Sync Chat Endpoint (for n8n / Make / Activepieces)
+ # ================================================================
+
+ # Known provider base URLs — auto-resolved from api_key prefix or model name
+ KNOWN_PROVIDERS = {
+ "deepseek": "https://api.deepseek.com/v1",
+ "openai": "https://api.openai.com/v1",
+ "mistral": "https://api.mistral.ai/v1",
+ "groq": "https://api.groq.com/openai/v1",
+ "together": "https://api.together.xyz/v1",
+ "openrouter": "https://openrouter.ai/api/v1",
+ "ollama": "https://ollama.com/api",
+ "opencode-zen": "https://opencode.ai/zen/v1",
+ "opencode-go": "https://opencode.ai/zen/go/v1",
+ "fireworks": "https://api.fireworks.ai/inference/v1",
+ "venice": "https://api.venice.ai/api/v1",
+ "kimi-code": "https://api.kimi.com/coding/v1",
+ "kimicode": "https://api.kimi.com/coding/v1",
+ }
+
+ # Model prefix → provider mapping for auto-detection
+ MODEL_PROVIDER_MAP = {
+ "deepseek": "deepseek",
+ "gpt-": "openai",
+ "o1": "openai",
+ "o3": "openai",
+ "o4": "openai",
+ "mistral": "mistral",
+ "llama": "groq",
+ "mixtral": "groq",
+ "kimi-for-coding": "kimi-code",
+ "kimi": "kimi-code",
+ }
+
+ def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
+ """Try to auto-resolve a base URL from provider name or model prefix."""
+ if provider and provider.lower() in KNOWN_PROVIDERS:
+ return KNOWN_PROVIDERS[provider.lower()]
+ if model:
+ model_lower = model.lower()
+ for prefix, prov in MODEL_PROVIDER_MAP.items():
+ if model_lower.startswith(prefix):
+ return KNOWN_PROVIDERS[prov]
+ return None
+
+ class SyncChatRequest(BaseModel):
+ message: str = Field(..., max_length=MAX_MESSAGE_LEN)
+ model: Optional[str] = Field(None, max_length=200)
+ session: Optional[str] = Field(None, max_length=100)
+ api_key: Optional[str] = Field(None, max_length=256)
+ base_url: Optional[str] = Field(None, max_length=MAX_URL_LEN)
+ provider: Optional[str] = Field(None, max_length=50)
+
+ @router.post("/v1/chat")
+ async def sync_chat(request: Request, body: SyncChatRequest):
+ if not getattr(request.state, "api_token", False):
+ raise HTTPException(403, "This endpoint requires an API token")
+ scopes = set(getattr(request.state, "api_token_scopes", []) or [])
+ if "chat" not in scopes:
+ raise HTTPException(403, "API token is not scoped for chat")
+ token_owner = getattr(request.state, "api_token_owner", None)
+
+ from core.models import ChatMessage
+ from src.llm_core import llm_call_async
+ from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
+
+ message = body.message.strip()
+ if not message:
+ raise HTTPException(400, "Message is required")
+
+ session_id = body.session
+ sess = None
+
+ # --- Case 1: Resume an existing session ---
+ if session_id and session_manager:
+ try:
+ sess = session_manager.get_session(session_id)
+ except (KeyError, Exception):
+ raise HTTPException(404, "Session not found")
+ # SECURITY: verify the API-token's user owns this session — without
+ # this any token holder could resume any user's chat by passing its
+ # ID. The token's user is on request.state.user (set by API-token
+ # middleware); fall back to require_user if not present.
+ try:
+ from src.auth_helpers import get_current_user as _gcu
+ _tok_user = token_owner or getattr(request.state, "user", None) or _gcu(request)
+ except Exception:
+ _tok_user = None
+ # Strict ownership (see _caller_owns_session): fail closed so a
+ # null-owner / cross-owner session can't be resumed by an arbitrary
+ # chat-scoped token.
+ _sess_owner = getattr(sess, "owner", None)
+ if not _caller_owns_session(_sess_owner, _tok_user):
+ raise HTTPException(404, "Session not found")
+
+ # --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
+ if not sess and body.api_key:
+ api_key = body.api_key.strip()
+ model = body.model or "deepseek-chat"
+
+ # Validate only token-supplied direct base_url; auto-resolved known-provider
+ # URLs are not subject to extra local/LAN blocking beyond existing provider logic.
+ direct_base_url = body.base_url.strip().rstrip("/") if body.base_url else None
+ if direct_base_url:
+ try:
+ base_url = validate_public_http_url(direct_base_url)
+ except ValueError as e:
+ detail = str(e).replace("URL", "base_url", 1)
+ raise HTTPException(400, detail)
+ else:
+ base_url = _resolve_base_url(model, body.provider)
+ if not base_url:
+ raise HTTPException(400,
+ "Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
+ "or provider ('deepseek', 'openai', 'groq', etc.)")
+ base_url = normalize_base(base_url)
+ endpoint_url = build_chat_url(base_url)
+
+ if not session_manager:
+ raise HTTPException(500, "Session manager not available")
+
+ sid = str(uuid.uuid4())
+ sess = session_manager.create_session(
+ session_id=sid, name="API Chat", endpoint_url=endpoint_url,
+ model=model, owner=token_owner,
+ )
+ sess.headers = build_headers(api_key, base_url)
+ session_manager.save_sessions()
+ session_id = sid
+
+ # --- Case 3: Fall back to first configured ModelEndpoint ---
+ if not sess:
+ db = SessionLocal()
+ try:
+ ep = _select_api_chat_fallback_endpoint(db, token_owner)
+ finally:
+ db.close()
+
+ if not ep:
+ raise HTTPException(400,
+ "No session, api_key, or configured endpoints. "
+ "Pass api_key + model, or configure an endpoint in Admin.")
+
+ base_url = normalize_base(ep.base_url)
+ endpoint_url = build_chat_url(base_url)
+ model = body.model or "auto"
+ api_key = ep.api_key
+ if getattr(ep, "provider_auth_id", None):
+ try:
+ from src.endpoint_resolver import resolve_endpoint_runtime
+ base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
+ endpoint_url = build_chat_url(base_url)
+ except Exception:
+ raise HTTPException(500, "Could not resolve endpoint credentials")
+
+ if model == "auto":
+ try:
+ async with httpx.AsyncClient(timeout=5) as client:
+ models_url = build_models_url(base_url)
+ hdrs = build_headers(api_key, base_url)
+ if models_url:
+ resp = await client.get(models_url, headers=hdrs)
+ resp.raise_for_status()
+ data = resp.json()
+ items = data if isinstance(data, list) else (data.get("data") or [])
+ ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
+ if not ids and isinstance(data, dict):
+ ids = [
+ m.get("name") or m.get("model")
+ for m in (data.get("models") or [])
+ if m.get("name") or m.get("model")
+ ]
+ else:
+ import json as _json
+ ids = _json.loads(ep.cached_models or "[]")
+ model = ids[0] if ids else "auto"
+ except Exception:
+ raise HTTPException(500, "Could not discover models from endpoint")
+
+ if not session_manager:
+ raise HTTPException(500, "Session manager not available")
+
+ sid = str(uuid.uuid4())
+ sess = session_manager.create_session(
+ session_id=sid, name="API Chat", endpoint_url=endpoint_url,
+ model=model, owner=token_owner,
+ )
+ if api_key:
+ sess.headers = build_headers(api_key, base_url)
+ session_manager.save_sessions()
+ session_id = sid
+
+ # --- Send message and get response ---
+ sess.add_message(ChatMessage("user", message))
+
+ messages = [{"role": m.role, "content": m.content} for m in sess.history]
+
+ reply = await llm_call_async(
+ sess.endpoint_url, sess.model, messages,
+ headers=sess.headers, timeout=120,
+ )
+ sess.add_message(ChatMessage("assistant", reply))
+ session_manager.save_sessions()
+
+ webhook_manager.fire_and_forget("chat.completed", {
+ "session_id": session_id, "model": sess.model,
+ "user_message": message[:2000], "response": reply[:2000],
+ })
+
+ return {"response": reply, "session_id": session_id, "model": sess.model}
+
+ return router
diff --git a/services/memory/__init__.py b/services/memory/__init__.py
index 31fa1d5fa..53fc80bd8 100644
--- a/services/memory/__init__.py
+++ b/services/memory/__init__.py
@@ -2,7 +2,7 @@
"""Memory service — persistent memory storage and retrieval."""
from .service import MemoryService, Memory, MemorySearchResult
-from .memory import MemoryManager, MemoryStoreUnreadable
+from .memory import MemoryManager
from .memory_vector import MemoryVectorStore
__all__ = [
@@ -10,6 +10,5 @@ __all__ = [
"Memory",
"MemorySearchResult",
"MemoryManager",
- "MemoryStoreUnreadable",
"MemoryVectorStore",
]
diff --git a/services/memory/memory.py b/services/memory/memory.py
index b9aaaa2a8..031c13ac4 100644
--- a/services/memory/memory.py
+++ b/services/memory/memory.py
@@ -5,16 +5,6 @@ application runtime instantiates ``src.memory.MemoryManager``, so keeping a
parallel implementation here risks silent drift between import paths.
"""
-from src.memory import (
- MemoryManager,
- MemoryStoreUnreadable,
- get_text_similarity,
- tokenize,
-)
+from src.memory import MemoryManager, get_text_similarity, tokenize
-__all__ = [
- "MemoryManager",
- "MemoryStoreUnreadable",
- "get_text_similarity",
- "tokenize",
-]
+__all__ = ["MemoryManager", "get_text_similarity", "tokenize"]
diff --git a/services/memory/memory_extractor.py b/services/memory/memory_extractor.py
index 11539263b..e5f609250 100644
--- a/services/memory/memory_extractor.py
+++ b/services/memory/memory_extractor.py
@@ -17,8 +17,6 @@ import os
import re
from typing import Optional
-from src.memory import MemoryStoreUnreadable
-
logger = logging.getLogger(__name__)
@@ -389,13 +387,7 @@ async def extract_and_store(
# Get owner from session
_owner = getattr(session, 'owner', None)
- # Strict load: this is a read-modify-write. Degrading to [] here would
- # save only the newly extracted facts and drop the entire store.
- try:
- existing = memory_manager.load_all_for_update()
- except MemoryStoreUnreadable as e:
- logger.error("Skipping auto memory extraction, store unreadable: %s", e)
- return
+ existing = memory_manager.load_all()
added = 0
for fact in facts:
@@ -634,18 +626,7 @@ async def audit_memories(
# Merge audited entries back with other users' entries
if owner:
- # Strict load: the merge below reconstructs the whole file. If this
- # degraded to [] we would save only this owner's audited slice and
- # destroy every other tenant's memories.
- try:
- all_entries = memory_manager.load_all_for_update()
- except MemoryStoreUnreadable as e:
- logger.error("Aborting memory audit save, store unreadable: %s", e)
- return {
- "before": before_count,
- "after": before_count,
- "error": "store_unreadable",
- }
+ all_entries = memory_manager.load_all()
audited_ids = {e["id"] for e in final_entries}
other_entries = [e for e in all_entries if e.get("owner") != owner and (e.get("owner") is not None)]
# Also keep legacy entries that weren't part of this audit
diff --git a/services/memory/skill_format.py b/services/memory/skill_format.py
index 628474b04..2b2dfb1b3 100644
--- a/services/memory/skill_format.py
+++ b/services/memory/skill_format.py
@@ -50,7 +50,7 @@ import json
import logging
import re
from dataclasses import dataclass, field
-from datetime import datetime, timezone
+from datetime import datetime
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@@ -441,4 +441,4 @@ class Skill:
def _now_iso() -> str:
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+ return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
diff --git a/services/tts/tts_service.py b/services/tts/tts_service.py
index dd37865a7..2120d7720 100644
--- a/services/tts/tts_service.py
+++ b/services/tts/tts_service.py
@@ -2,7 +2,6 @@
"""Multi-provider TTS service — dispatches to local Kokoro, OpenAI-compatible API, or browser."""
import io
-import os
import wave
import logging
import hashlib
@@ -42,11 +41,6 @@ class TTSService:
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True)
self._kokoro = None # lazy-init
-
- try:
- self.max_cache_bytes = int(os.getenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", 500 * 1024 * 1024))
- except ValueError:
- self.max_cache_bytes = 500 * 1024 * 1024
# ── Settings ──
@@ -95,53 +89,6 @@ class TTSService:
ext = ".mp3" if (len(data) >= 3 and (data[:3] == b'ID3' or (data[0] == 0xff and (data[1] & 0xe0) == 0xe0))) else ".wav"
(self.cache_dir / f"{key}{ext}").write_bytes(data)
- self._enforce_cache_limit()
-
- def _enforce_cache_limit(self):
- """Evicts oldest files if the cache exceeds the configured byte limit."""
- if self.max_cache_bytes <= 0:
- return
-
- try:
- files = []
- total_size = 0
-
- # Safely scan files and sum sizes, ignoring files deleted mid-scan
- for f in self.cache_dir.iterdir():
- try:
- if f.is_file() and f.suffix.lower() in (".mp3", ".wav"):
- files.append(f)
- total_size += f.stat().st_size
- except OSError:
- continue
-
- if total_size > self.max_cache_bytes:
- logger.info(
- f"TTS cache ({total_size} bytes) exceeded limit ({self.max_cache_bytes} bytes). Evicting oldest files."
- )
-
- # Sort files by modification time (oldest first)
- try:
- files.sort(key=lambda f: f.stat().st_mtime)
- except OSError as e:
- logger.warning(f"Failed to sort cache files by mtime: {e}")
-
- # Trim down to 80% of max capacity
- target_size = self.max_cache_bytes * 0.8
-
- while files and total_size > target_size:
- f = files.pop(0)
- try:
- size = f.stat().st_size
- f.unlink()
- total_size -= size
- except OSError as e:
- logger.warning(f"Failed to evict cache file {f}: {e}")
- continue
-
- except Exception as e:
- logger.warning(f"Error enforcing TTS cache limit: {e}", exc_info=True)
-
def clear_cache(self):
count = 0
for f in self.cache_dir.glob("*.*"):
diff --git a/src/agent_loop.py b/src/agent_loop.py
index cca93fe56..592ebaec1 100644
--- a/src/agent_loop.py
+++ b/src/agent_loop.py
@@ -12,7 +12,7 @@ import json
import re
import time
import logging
-from typing import Any, AsyncGenerator, List, Dict, Optional, Set
+from typing import AsyncGenerator, List, Dict, Optional, Set
from urllib.parse import urlparse
from src.llm_core import (
diff --git a/src/ai_interaction.py b/src/ai_interaction.py
index e777ca32a..9ee97368f 100644
--- a/src/ai_interaction.py
+++ b/src/ai_interaction.py
@@ -22,7 +22,6 @@ import time
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
from src.constants import GENERATED_IMAGES_DIR
-from src.memory import MemoryStoreUnreadable
logger = logging.getLogger(__name__)
@@ -385,15 +384,7 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
return {"error": "Memory text cannot be empty"}
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
- # Strict load: this is a read-modify-write, and it is the path an
- # ordinary "remember that I prefer X" takes. Degrading to [] here would
- # save just this one entry over a store we only failed to read,
- # atomically destroying every memory in it (issue #5673).
- try:
- memories = _memory_manager.load_all_for_update()
- except MemoryStoreUnreadable as e:
- logger.error("Refusing to add memory, store unreadable: %s", e)
- return {"error": "Memory store is temporarily unreadable — nothing was saved."}
+ memories = _memory_manager.load_all()
memories.append(entry)
_memory_manager.save(memories)
diff --git a/src/integrations.py b/src/integrations.py
index 52dd4b2d1..aa6c4982e 100644
--- a/src/integrations.py
+++ b/src/integrations.py
@@ -1,14 +1,11 @@
-import ipaddress
import json
import os
-import time
import uuid
import logging
import re
from typing import Dict, List, Optional, Any
from urllib.parse import urljoin, urlparse, urlunparse
-import httpcore
import httpx
from fastapi import HTTPException
@@ -357,152 +354,6 @@ def _find_integration(identifier: str) -> Optional[Dict[str, Any]]:
return None
-# httpcore raises its own exception hierarchy; map the ones a simple request can
-# surface back to their httpx equivalents so the caller's `except httpx.*` blocks
-# below behave exactly as they did with the default transport.
-_HTTPCORE_TO_HTTPX_EXC = {
- httpcore.ConnectError: httpx.ConnectError,
- httpcore.ConnectTimeout: httpx.ConnectTimeout,
- httpcore.NetworkError: httpx.NetworkError,
- httpcore.PoolTimeout: httpx.PoolTimeout,
- httpcore.ProtocolError: httpx.ProtocolError,
- httpcore.ReadError: httpx.ReadError,
- httpcore.ReadTimeout: httpx.ReadTimeout,
- httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
- httpcore.TimeoutException: httpx.TimeoutException,
- httpcore.WriteError: httpx.WriteError,
- httpcore.WriteTimeout: httpx.WriteTimeout,
-}
-
-
-class _PinnedAsyncBackend(httpcore.AsyncNetworkBackend):
- """Network backend that connects only to the pre-validated IPs, in order.
-
- Every address here came out of the single SSRF resolution, so moving to the
- next one after a connect failure is not re-resolution — it's ordinary
- multi-address fallback restricted to the set the guard already approved.
- httpcore takes TLS SNI and the ``Host`` header from the request URL rather
- than the connect host, so pinning the socket destination leaves certificate
- validation and vhost routing pointed at the original hostname.
- """
-
- def __init__(self, ips: List[ipaddress._BaseAddress]):
- self._ips = [str(ip) for ip in ips]
- self._real = httpcore.AnyIOBackend()
-
- async def connect_tcp(self, host, port, timeout=None, local_address=None,
- socket_options=None):
- # One shared connect budget: each attempt gets the time left until the
- # original deadline, so N dead addresses can't stretch the connect
- # phase to N * timeout.
- deadline = None if timeout is None else time.monotonic() + timeout
- last_exc: Optional[Exception] = None
- for ip in self._ips:
- remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
- try:
- return await self._real.connect_tcp(
- ip, port, remaining, local_address, socket_options
- )
- except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc:
- last_exc = exc
- if deadline is not None and time.monotonic() >= deadline:
- break
- raise last_exc
-
- async def connect_unix_socket(self, path, timeout=None, socket_options=None):
- return await self._real.connect_unix_socket(path, timeout, socket_options)
-
- async def sleep(self, seconds: float) -> None:
- return await self._real.sleep(seconds)
-
-
-class _PinnedAsyncTransport(httpx.AsyncBaseTransport):
- """httpx transport that pins the TCP connect to the pre-resolved IP(s).
-
- Kept local, mirroring the per-module pinned transports web fetch and
- webhook delivery already carry, rather than coupling api_call to the
- webhook subsystem. The request URL passes through unchanged, so SNI and the
- ``Host`` header stay the original hostname; only the socket destination is
- pinned, which is what closes the rebinding window.
- """
-
- def __init__(self, ips: List[ipaddress._BaseAddress]):
- self._pinned_ips = list(ips)
- self._pool = httpcore.AsyncConnectionPool(
- # Reuse the CA trust the default httpx client would build (certifi
- # plus SSL_CERT_FILE / SSL_CERT_DIR when trust_env is set) so
- # swapping in this transport doesn't quietly change which chains
- # verify. ssl.create_default_context() would use system roots.
- ssl_context=httpx.create_ssl_context(),
- http1=True,
- http2=False,
- network_backend=_PinnedAsyncBackend(ips),
- )
-
- async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
- core_req = httpcore.Request(
- method=request.method,
- url=httpcore.URL(
- scheme=request.url.raw_scheme,
- host=request.url.raw_host,
- port=request.url.port,
- target=request.url.raw_path,
- ),
- headers=request.headers.raw,
- content=request.stream,
- extensions=request.extensions,
- )
- try:
- core_resp = await self._pool.handle_async_request(core_req)
- content = b"".join([chunk async for chunk in core_resp.aiter_stream()])
- await core_resp.aclose()
- except Exception as exc:
- mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
- if mapped is not None:
- raise mapped(str(exc)) from exc
- raise
- return httpx.Response(
- status_code=core_resp.status,
- headers=core_resp.headers,
- content=content,
- extensions=core_resp.extensions,
- )
-
- async def aclose(self) -> None:
- await self._pool.aclose()
-
-
-def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:
- """Return every entry that parses as an IP address, de-duplicated, order
- preserved.
-
- check_outbound_url only reports ok when *all* of these classify as safe, so
- the whole list is guard-approved and any of them is a legitimate connect
- target. Skipping unparseable entries mirrors how the guard walks the same
- resolver output.
-
- De-duplication matters because the resolver is getaddrinfo(host, None) with
- no socktype filter, so glibc reports the same address once per socktype
- (SOCK_STREAM/SOCK_DGRAM/SOCK_RAW) — a single-homed host comes back three
- times. Without this, the connect fallback would spend the shared deadline
- retrying one dead address instead of moving on to a genuinely different one.
- """
- ips: List[ipaddress._BaseAddress] = []
- seen = set()
- for raw in raw_ips:
- if not isinstance(raw, str):
- continue
- try:
- ip = ipaddress.ip_address(raw.split("%")[0]) # strip IPv6 zone id
- except ValueError:
- continue
- if ip in seen:
- continue
- seen.add(ip)
- ips.append(ip)
- return ips
-
-
async def execute_api_call(
integration_id: str,
method: str,
@@ -558,31 +409,13 @@ async def execute_api_call(
# loopback for locked-down deployments. Private stays allowed by default
# because LAN integrations (Home Assistant, Miniflux, ntfy) are the
# primary use case.
- from src.url_safety import check_outbound_url, _default_resolver
+ from src.url_safety import check_outbound_url
block_private = os.getenv(
"INTEGRATION_API_BLOCK_PRIVATE_IPS", "false"
).lower() == "true"
- # Resolve the host exactly once and remember the IPs the guard validated so
- # the request below can be pinned to them. check_outbound_url only reports
- # (ok, reason); a plain httpx client re-resolves the host at connect time,
- # which reopens a DNS-rebinding TOCTOU — a base_url host that answers with a
- # public IP for the guard and then flips to 169.254.169.254 for the connect
- # would reach cloud metadata with the integration's auth headers attached.
- resolved_ips: List[str] = []
-
- def _recording_resolver(host: str) -> List[str]:
- ips = _default_resolver(host)
- resolved_ips[:] = ips
- return ips
-
- ok, reason = check_outbound_url(
- url, block_private=block_private, resolver=_recording_resolver
- )
+ ok, reason = check_outbound_url(url, block_private=block_private)
if not ok:
return {"error": f"URL rejected: {reason}", "exit_code": 1}
- pinned_ips = _validated_ips(resolved_ips)
- if not pinned_ips:
- return {"error": "URL rejected: host did not resolve to a usable address", "exit_code": 1}
method = method.upper()
@@ -622,9 +455,7 @@ async def execute_api_call(
auth = httpx.BasicAuth(parts[0], parts[1])
try:
- async with httpx.AsyncClient(
- timeout=30.0, transport=_PinnedAsyncTransport(pinned_ips)
- ) as client:
+ async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.request(
method,
url,
diff --git a/src/llm_core.py b/src/llm_core.py
index 3e84c1060..4dec32376 100644
--- a/src/llm_core.py
+++ b/src/llm_core.py
@@ -1237,27 +1237,15 @@ def _anthropic_rejects_temperature(model: str) -> bool:
return False
# `(?= 4.7 (issue #5753). Without
- # this, every Opus 5 call kept `temperature` and failed with HTTP 400 — visible
- # only on paths that pass a temperature, e.g. scheduled tasks inheriting
- # `stream_agent_loop`'s 0.3 default, which returned empty responses.
- match = re.search(
- r"(?= 4.7. Dated 4.7+ snapshots (`claude-opus-4-7-
+ # 20260201`) keep their explicit minor and are still matched.
+ match = re.search(r"(?= (4, 7)
+ return (int(match.group(1)), int(match.group(2))) >= (4, 7)
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
# API accepts "high", "medium", "low", "none" — see
diff --git a/src/memory.py b/src/memory.py
index 92efbf5b2..1d8cdbc1e 100644
--- a/src/memory.py
+++ b/src/memory.py
@@ -10,18 +10,6 @@ from datetime import datetime
logger = logging.getLogger(__name__)
-
-class MemoryStoreUnreadable(RuntimeError):
- """memory.json exists on disk but could not be read or parsed.
-
- "The contents are unknown" is categorically different from "there are no
- memories". A read-modify-write caller that conflates the two appends to an
- empty view and then persists it, destroying the whole store — the writes
- are atomic, so the loss is durable. Raised by
- :meth:`MemoryManager.load_all_for_update` so those callers fail closed.
- """
-
-
def tokenize(text: str) -> List[str]:
"""Simple tokenizer that splits on whitespace and removes punctuation."""
return [word.strip('.,!?";') for word in text.split()]
@@ -122,69 +110,21 @@ class MemoryManager:
with open(self.memory_file, 'w', encoding='utf-8') as f:
json.dump([], f, ensure_ascii=False, indent=2)
- def _read_entries(self) -> List[Dict]:
- """Parse the store, or raise :class:`MemoryStoreUnreadable`.
-
- Returns ``[]`` only when the file genuinely does not exist. Every other
- failure mode raises, so callers can tell "no memories" apart from
- "couldn't read the memories".
- """
+ def load_all(self) -> List[Dict]:
+ """Load all memory entries from JSON file (unfiltered)."""
if not os.path.exists(self.memory_file):
return []
try:
with open(self.memory_file, "r", encoding="utf-8") as f:
data = json.load(f)
- except OSError as e:
- # PermissionError is an OSError (a scanner holding the file, a
- # permissions problem, bad media).
- raise MemoryStoreUnreadable(
- f"cannot read {self.memory_file}: {e}"
- ) from e
- except json.JSONDecodeError as e:
- # This is the branch that actually destroyed stores: the file reads
- # back fine, so nothing stops the save that follows. A truncated
- # memory.json is reachable because core/database.py rewrites it with
- # a plain open(..,"w") + json.dump during migration.
- #
- # Preserved behaviour: a corrupt store still gets one shot at the
- # pre-JSON memory.txt migration. Only raise when that finds nothing,
- # so we never report "empty" for a store we simply failed to parse.
- legacy = self._migrate_from_legacy()
- if legacy:
- return legacy
- raise MemoryStoreUnreadable(
- f"{self.memory_file} is not valid JSON: {e}"
- ) from e
-
- if not isinstance(data, list):
- raise MemoryStoreUnreadable(
- f"{self.memory_file} is not a JSON array (got {type(data).__name__})"
- )
- return self._validate_entries(data)
-
- def load_all(self) -> List[Dict]:
- """Load all memory entries from JSON file (unfiltered).
-
- Lenient by design: this feeds display, search, and context-injection
- paths, so an unreadable store degrades to an empty list rather than
- breaking chat. Never build a value from this that you intend to save
- back — use :meth:`load_all_for_update` for that.
- """
- try:
- return self._read_entries()
- except MemoryStoreUnreadable as e:
+ if isinstance(data, list):
+ return self._validate_entries(data)
+ except (json.JSONDecodeError, PermissionError) as e:
logger.error("Error loading memory.json: %s", e)
- return []
+ return self._migrate_from_legacy()
- def load_all_for_update(self) -> List[Dict]:
- """Load for a read-modify-write cycle.
-
- Propagates :class:`MemoryStoreUnreadable` instead of degrading to ``[]``
- so a caller can never append to an empty view and persist it over a
- store that was only temporarily unreadable (issue #5673).
- """
- return self._read_entries()
+ return []
def load(self, owner: str = None) -> List[Dict]:
"""Load memory entries, optionally filtered by owner."""
@@ -195,12 +135,7 @@ class MemoryManager:
def claim_ownerless(self, owner: str):
"""Assign all ownerless memory entries to the given owner."""
- try:
- entries = self.load_all_for_update()
- except MemoryStoreUnreadable as e:
- # Skip the sweep rather than rewrite the store from an unknown view.
- logger.error("Skipping ownerless claim, memory store unreadable: %s", e)
- return
+ entries = self.load_all()
changed = False
claimed = 0
for entry in entries:
@@ -300,12 +235,7 @@ class MemoryManager:
if not ids:
return
id_set = set(ids)
- try:
- entries = self.load_all_for_update()
- except MemoryStoreUnreadable as e:
- # Best-effort counter; never worth rewriting the store blind.
- logger.error("Skipping uses bump, memory store unreadable: %s", e)
- return
+ entries = self.load_all()
changed = False
for e in entries:
if e.get("id") in id_set:
diff --git a/src/memory_provider.py b/src/memory_provider.py
index 8974a6e84..925c59192 100644
--- a/src/memory_provider.py
+++ b/src/memory_provider.py
@@ -157,11 +157,7 @@ class NativeMemoryProvider(MemoryProvider):
if metadata:
entry["metadata"] = dict(metadata)
- # Strict load: read-modify-write. `load_all` degrades an unreadable
- # store to [], which would save this single entry over everything
- # already stored (issue #5673). The provider API has no error channel,
- # so MemoryStoreUnreadable propagates to the caller.
- memories = self.memory_manager.load_all_for_update()
+ memories = self.memory_manager.load_all()
memories.append(entry)
self.memory_manager.save(memories)
@@ -227,10 +223,7 @@ class NativeMemoryProvider(MemoryProvider):
]
async def delete(self, memory_id: str, *, owner: Optional[str] = None) -> bool:
- # Strict load for the same reason: `remaining` is derived from this
- # list and saved back, so it must never be built from a store we
- # failed to read.
- memories = self.memory_manager.load_all_for_update()
+ memories = self.memory_manager.load_all()
remaining = []
deleted_id = None
diff --git a/src/tool_parsing.py b/src/tool_parsing.py
index 98dc1b5f6..2885cc00f 100644
--- a/src/tool_parsing.py
+++ b/src/tool_parsing.py
@@ -187,12 +187,8 @@ _FUNCTION_MODEL_NAME_RE = re.compile(
_FUNCTION_MODEL_PARAMS_OPEN_RE = re.compile(r"\s*", re.IGNORECASE)
_FUNCTION_MODEL_PARAMS_CLOSE_RE = re.compile(r" ", re.IGNORECASE)
_QWEN_ROLE_MARKER_RE = re.compile(r"?\|(?:assistant|assistan|user|system|tool)\|>?|\|end\|>?", re.IGNORECASE)
-# At least one pipe is required around `end`. Both pipes used to be optional
-# (`\|?end\|?`), which also matched a bare `end` on its own line and deleted it
-# from ordinary prose and from Ruby/Lua/shell snippets that close blocks with
-# one; see #5547. `|end`, `end|`, `|end|` and `/|end|` still strip as before.
_QWEN_BARE_MARKER_RE = re.compile(
- r"(?:^|[\t\r\n ])(?:/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|"
+ r"(?:^|[\t\r\n ])(?:\|?end\|?|/?\|end\|)(?=[\t\r\n ]|$)|"
r"(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)",
re.IGNORECASE,
)
diff --git a/src/tools/system.py b/src/tools/system.py
index c2eb9ceab..813d57df2 100644
--- a/src/tools/system.py
+++ b/src/tools/system.py
@@ -46,9 +46,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
- action = (args.get("action") or "").strip().lower()
- if not action:
- return {"error": "action is required (list|view|view_ref|add|edit|patch|publish|delete|search)", "exit_code": 1}
+ action = (args.get("action") or "").lower()
from services.memory.skills import SkillsManager
from services.memory.skill_format import Skill, slugify
from src.constants import DATA_DIR
@@ -57,7 +55,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
# Accept legacy `skill_id` as an alias for `name`.
name = (args.get("name") or args.get("skill_id") or "").strip()
- if action in ("list", "index"):
+ if action in ("list", "index", ""):
all_skills = sm.load(owner=owner)
if not all_skills:
return {"results": "No skills yet. Create one with action='add'."}
diff --git a/static/app.js b/static/app.js
index 2f1e8d4bf..97f0ae77e 100644
--- a/static/app.js
+++ b/static/app.js
@@ -10,14 +10,14 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
import ragModule from './js/rag.js';
import presetsModule from './js/presets.js';
import searchModule from './js/search.js';
-import chatModule from './js/chat.js?v=20260801fix1';
+import chatModule from './js/chat.js?v=20260722ctxheader4';
import compareModule from './js/compare/index.js?v=20260723compareicon2';
import documentModule from './js/document.js?v=20260722emailfastindex1';
import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js';
import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
-import sessionModule from './js/sessions.js';
+import sessionModule from './js/sessions.js?v=20260722ctxheader4';
import memoryModule from './js/memory.js?v=20260722memoryloading1';
import voiceRecorderModule from './js/voiceRecorder.js';
import censorModule from './js/censor.js';
@@ -1689,20 +1689,12 @@ function initializeEventListeners() {
const newMemoryInput = el('new-memory-input');
if (newMemoryInput) {
- // keydown, not the deprecated keypress: keypress is not guaranteed to
- // fire for Enter everywhere, which left the Add Memory form with no
- // working submit path (#5828).
- newMemoryInput.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.isComposing) {
- e.preventDefault();
+ newMemoryInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
memoryModule.addNewMemory();
}
});
}
- const newMemoryAddBtn = el('new-memory-add-btn');
- if (newMemoryAddBtn) {
- newMemoryAddBtn.addEventListener('click', () => memoryModule.addNewMemory());
- }
// Voice recording is handled by the dual-purpose send/mic button (see below)
@@ -3916,10 +3908,85 @@ function startOdysseusApp() {
const messageInput = el('message');
const modelPickerWrap = document.getElementById('model-picker-wrap');
- // ArrowUp/ArrowDown prompt recall on #message lives in
- // static/js/composerArrowUpRecall.js (wired from chat.js). Do not re-add a
- // copy here: two capture-phase listeners on the same textarea meant the one
- // without the draft guard won and ate unsent multi-line prompts (#5862).
+ function _readComposerPromptHistory() {
+ const chatBox = document.getElementById('chat-history');
+ if (!chatBox) return [];
+ return Array.from(chatBox.querySelectorAll('.msg-user'))
+ .reverse()
+ .map(msg => {
+ const body = msg.querySelector('.body');
+ return msg.dataset?.raw || (body ? body.textContent : '') || '';
+ })
+ .filter(Boolean);
+ }
+
+ if (messageInput && !messageInput._odysseusPromptRecallCapture) {
+ messageInput._odysseusPromptRecallCapture = true;
+ let recallHistory = [];
+ let recallIndex = -1;
+ let lastRecalled = '';
+ const norm = (v) => String(v || '').replace(/\r\n/g, '\n').trimEnd();
+ messageInput.addEventListener('input', () => {
+ if (norm(messageInput.value) === norm(lastRecalled)) return;
+ recallHistory = [];
+ recallIndex = -1;
+ lastRecalled = '';
+ try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
+ }, true);
+ messageInput.addEventListener('keydown', (e) => {
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
+ if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
+ if (window._ghostAutocomplete?.isActive?.()) return;
+ const fresh = _readComposerPromptHistory();
+ const history = fresh.length ? fresh : recallHistory;
+ if (!history.length) return;
+ const current = norm(messageInput.value);
+ let currentIndex = current ? history.findIndex(item => norm(item) === current) : -1;
+ if (current && currentIndex < 0 && current === norm(lastRecalled)) currentIndex = recallIndex;
+ if (current && currentIndex < 0) {
+ const markedIndex = Number(messageInput.dataset.odysseusRecallIndex);
+ if (Number.isInteger(markedIndex) && markedIndex >= 0 && markedIndex < history.length) {
+ currentIndex = markedIndex;
+ }
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ if (e.key === 'ArrowDown') {
+ if (currentIndex < 0) return;
+ const nextIndex = currentIndex - 1;
+ if (nextIndex < 0) {
+ recallHistory = history;
+ recallIndex = -1;
+ lastRecalled = '';
+ try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
+ messageInput.value = '';
+ try { messageInput.selectionStart = messageInput.selectionEnd = 0; } catch {}
+ try { uiModule.autoResize(messageInput); } catch {}
+ return;
+ }
+ const recalled = history[nextIndex];
+ recallHistory = history;
+ recallIndex = nextIndex;
+ lastRecalled = recalled;
+ try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
+ messageInput.value = recalled;
+ try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
+ try { uiModule.autoResize(messageInput); } catch {}
+ return;
+ }
+ const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
+ const recalled = history[nextIndex];
+ if (!recalled) return;
+ recallHistory = history;
+ recallIndex = nextIndex;
+ lastRecalled = recalled;
+ try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
+ messageInput.value = recalled;
+ try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
+ try { uiModule.autoResize(messageInput); } catch {}
+ }, true);
+ }
const _sendIcon = ' ';
const _micIcon = ' ';
diff --git a/static/index.html b/static/index.html
index fea4e20ac..8257660fe 100644
--- a/static/index.html
+++ b/static/index.html
@@ -250,9 +250,9 @@
-
+
-
+
@@ -365,7 +365,6 @@
Add a memory — e.g. 'I prefer concise replies'
- Add
@@ -1006,7 +1005,7 @@
var tips = mobile ? phone : desktop;
var el = document.getElementById('welcome-tip');
if (el) {
- el.textContent = tips[Math.floor(Math.random() * tips.length)];
+ el.textContent = 'Pick a model if you want, or just type.';
}
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
if (d.version) window._appVersion = d.version;
@@ -2505,7 +2504,7 @@
-
+
@@ -2523,7 +2522,7 @@
-
+
diff --git a/static/js/chat.js b/static/js/chat.js
index 3c8bbe850..ea2d8c1bb 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -349,9 +349,6 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
async function _adoptOpenedSessionBeforeAutoCreate() {
if (!sessionModule || !sessionModule.getCurrentSessionId || sessionModule.getCurrentSessionId()) return true;
- // Don't adopt a stale session when the user explicitly started a New Chat
- // (pending state set) — the send path must materialize the pending session.
- if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) return false;
const activeRowId = document.querySelector('.list-item.active-session[data-session-id], .session-item.active[data-session-id]')?.dataset?.sessionId || '';
const hashId = _hashSessionCandidate();
const lastSelectedId = String(window.__odysseusLastSelectedSessionId || '').trim();
@@ -1406,8 +1403,6 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
currentAccumulated = '';
currentHolder = null;
- let abortCtrl = null;
- let streamingTTS = false;
try {
// Re-enable auto-scroll when user sends a message
uiModule.setAutoScroll(true);
@@ -1721,7 +1716,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
- abortCtrl = new AbortController();
+ const abortCtrl = new AbortController();
abortCtrl._reason = '';
currentAbort = abortCtrl;
@@ -1902,7 +1897,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
let isThinking = false;
let thinkingStartTime = null;
// Streaming TTS: synthesize sentence-by-sentence during streaming
- streamingTTS = !!(window.aiTTSManager && window.aiTTSManager.autoPlay && window.aiTTSManager.available);
+ const streamingTTS = !!(window.aiTTSManager && window.aiTTSManager.autoPlay && window.aiTTSManager.available);
if (streamingTTS) window.aiTTSManager.streamingStart();
// Multi-bubble agent tracking
let roundHolder = holder; // Current AI text bubble (changes per round)
@@ -4792,8 +4787,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (msgIndex < 0) return;
const bodyEl = userMsgElement.querySelector('.body');
- let currentText = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
- currentText = currentText.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
+ const currentText = bodyEl ? bodyEl.textContent.trim().replace(/\s*\[\d+ attachment\(s\)\]$/, '') : '';
// Replace body with an editable textarea
const editor = document.createElement('textarea');
diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js
index 1d6e2e4a9..10709679d 100644
--- a/static/js/chatRenderer.js
+++ b/static/js/chatRenderer.js
@@ -478,10 +478,7 @@ const DSML_STRAY_RE = /<\s*\/?\s*[||]+\s*DSML\s*[||]+[^>]*>/gi;
const DSML_INVOKE_RE = /<\s*[||]+\s*DSML\s*[||]+\s*invoke\b[^>]*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*invoke\s*>|$)/gi;
const RAW_OPENAI_TOOL_JSON_RE = /(?:\[\s*)?\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"[^"]*"\s*,\s*"type"\s*:\s*"function"\s*\}\s*\]?/gi;
const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi;
-// Keep in sync with _QWEN_BARE_MARKER_RE in src/tool_parsing.py. At least one
-// pipe is required around `end`: with both optional (`\|?end\|?`) this also ate
-// a bare `end` on its own line, breaking Ruby/Lua/shell snippets (#5547).
-const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
+const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\|?end\|?|\/?\|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
// Self-narration about tool results (model echoing stdout/exit_code)
const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
diff --git a/static/js/composerArrowUpRecall.js b/static/js/composerArrowUpRecall.js
index 83141bfe9..e0b20d6b4 100644
--- a/static/js/composerArrowUpRecall.js
+++ b/static/js/composerArrowUpRecall.js
@@ -143,9 +143,9 @@ export function wireArrowUpRecall(composer, getUserMessages, options = {}) {
return;
}
- // ArrowUp walks older prompts. An unmatched draft already returned above,
- // so reaching here means the composer is empty or holds a recalled prompt
- // — the caret-navigation case is never hijacked.
+ // ArrowUp owns prompt history in the chat composer. If the current text
+ // is not already a recalled prompt, start from newest instead of letting
+ // the browser move the caret inside the textarea.
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
const recalled = history[nextIndex];
if (!recalled) {
diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js
index 32b906ddc..6a0d3e294 100644
--- a/static/js/emailLibrary.js
+++ b/static/js/emailLibrary.js
@@ -13,7 +13,7 @@ import { makeWindowDraggable } from './windowDrag.js';
import {
_esc, _escLinkify, _extractName, _parseTurnMeta,
_formatBubbleDate, _formatRecipients, _senderColor, _initials,
- _sanitizeHtml, _renderEmailSummaryError,
+ _sanitizeHtml,
_TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO,
_TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS,
} from './emailLibrary/utils.js';
@@ -7259,11 +7259,12 @@ async function _generateSummary(reader, data, btn) {
if (label) label.textContent = 'Summary';
}
} else {
- _renderEmailSummaryError(content, result);
+ content.innerHTML = `
${_esc(result.error || 'Failed to summarize')} `;
+ panel.remove();
}
} catch (e) {
sp.destroy();
- _renderEmailSummaryError(content, null);
+ panel.remove();
if (uiModule) uiModule.showError?.('Failed to summarize');
} finally {
if (btn) btn.disabled = false;
diff --git a/static/js/emailLibrary/utils.js b/static/js/emailLibrary/utils.js
index f634c9949..82a5c86ec 100644
--- a/static/js/emailLibrary/utils.js
+++ b/static/js/emailLibrary/utils.js
@@ -30,25 +30,6 @@ export function _esc(text) {
return div.innerHTML;
}
-const _EMAIL_SUMMARY_ERROR_MESSAGES = Object.freeze({
- email_summary_missing_body: 'No email body to summarize',
- email_summary_not_configured: 'No model configured for email summaries',
- email_summary_empty: 'The model returned an empty summary',
- email_summary_unavailable: 'Failed to summarize',
-});
-
-export function _emailSummaryErrorMessage(result) {
- const code = String(result?.error_code || '');
- return _EMAIL_SUMMARY_ERROR_MESSAGES[code] || 'Failed to summarize';
-}
-
-export function _renderEmailSummaryError(container, result) {
- const message = container.ownerDocument.createElement('span');
- message.style.color = 'var(--red)';
- message.textContent = _emailSummaryErrorMessage(result);
- container.replaceChildren(message);
-}
-
function _attrEsc(text) {
return String(text ?? '')
.replace(/"/g, '"')
diff --git a/static/js/markdown.js b/static/js/markdown.js
index f249facc9..8735b83e7 100644
--- a/static/js/markdown.js
+++ b/static/js/markdown.js
@@ -758,36 +758,30 @@ export function mdToHtml(src, opts) {
// Remove empty paragraphs
s = s.replace(/
<\/p>/g, '');
- // Every restore below passes a function replacer rather than the block string
- // itself. With a string replacement, `String.replace` reads `$&`, `` $` ``,
- // `$'` and `$$` in the *replacement* as substitution patterns, so a restored
- // block containing them is corrupted: `$&` re-inserts the placeholder, `` $` ``
- // and `$'` splice in the surrounding document, and `$$` collapses to `$`. Those
- // sequences are ordinary content in fenced code (`perl -pe 's/x/$& y/'`,
- // `echo "$$USD"`). A function replacer inserts its return value verbatim.
-
// CRITICAL: Restore allowed HTML blocks first
allowedHtmlBlocks.forEach((block, index) => {
- s = s.replace(`___ALLOWED_HTML_${index}___`, () => block);
+ s = s.replace(`___ALLOWED_HTML_${index}___`, block);
});
// Restore math blocks
mathBlocks.forEach((block, index) => {
- s = s.replace(`___MATH_BLOCK_${index}___`, () => block);
+ s = s.replace(`___MATH_BLOCK_${index}___`, block);
});
// Restore mermaid diagram blocks
mermaidBlocks.forEach((block, index) => {
- s = s.replace(`___MERMAID_BLOCK_${index}___`, () => block);
+ s = s.replace(`___MERMAID_BLOCK_${index}___`, block);
});
// CRITICAL: Restore code blocks at the end
codeBlocks.forEach((block, index) => {
- s = s.replace(`___CODE_BLOCK_${index}___`, () => block);
+ s = s.replace(`___CODE_BLOCK_${index}___`, block);
});
// Restore inline code spans last, so placeholders carried inside restored
- // /allowed-HTML blocks are resolved too.
+ // /allowed-HTML blocks are resolved too. The function replacer keeps the
+ // escaped code literal — e.g. a shell snippet like `echo $1` is not treated
+ // as a regex back-reference.
inlineCodeBlocks.forEach((block, index) => {
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
});
diff --git a/static/js/sessions.js b/static/js/sessions.js
index edf83c8a4..cf59d478c 100644
--- a/static/js/sessions.js
+++ b/static/js/sessions.js
@@ -1847,10 +1847,6 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
const _isTransientChat = !!_meta && (_meta.folder === 'Assistant' || _meta.folder === 'Tasks');
if (!_isTransientChat) {
Storage.set('lastSessionId', id);
- // Update URL hash without triggering hashchange handler
- if (window.location.hash !== '#' + id) {
- history.replaceState(null, '', '#' + id);
- }
}
// Restore character preset for persistent chats
try {
@@ -2317,7 +2313,6 @@ export async function materializePendingSession() {
currentSessionId = payload.id;
if (!isIncognito) {
Storage.set('lastSessionId', payload.id);
- history.replaceState(null, '', '#' + payload.id);
}
// Reload the sidebar in the background. Awaiting this used to block the first
diff --git a/static/js/settings.js b/static/js/settings.js
index 540acff00..72936adee 100644
--- a/static/js/settings.js
+++ b/static/js/settings.js
@@ -3031,14 +3031,12 @@ async function initEmailAccountsSettings() {
const body = {
name: el('eaf-name').value.trim() || el('eaf-from').value.trim(),
from_address: el('eaf-from').value.trim(),
- display_name: el('eaf-display-name').value.trim(),
imap_host: el('eaf-imap-host').value.trim(),
imap_port: parseInt(el('eaf-imap-port').value) || 993,
imap_user: el('eaf-imap-user').value.trim(),
imap_starttls: el('eaf-imap-starttls').checked,
smtp_host: el('eaf-smtp-host').value.trim(),
smtp_port: parseInt(el('eaf-smtp-port').value) || 587,
- smtp_security: el('eaf-smtp-security').value,
smtp_user: el('eaf-imap-user').value.trim(),
};
if (!body.name) { el('eaf-msg').textContent = 'Enter a Name or Email first'; el('eaf-msg').style.color = 'var(--red)'; return; }
@@ -5790,30 +5788,29 @@ export function close() {
window.history.replaceState(null, '', clean);
const success = sp.has('email_oauth_success');
const errMsg = sp.get('email_oauth_error') || '';
- // Open settings → integrations once the document is ready. This module owns
- // the open() API, so it does not need to wait for a window-level alias.
- function _showResult() {
- open('integrations');
- // Brief toast-style banner.
- const banner = document.createElement('div');
- banner.textContent = success
- ? 'Google account connected — email is ready'
- : `Google OAuth failed: ${errMsg || 'unknown error'}`;
- Object.assign(banner.style, {
- position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
- background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
- color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
- fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
- boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
- });
- document.body.appendChild(banner);
- setTimeout(() => banner.remove(), 4000);
- }
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', _showResult, { once: true });
- } else {
- _showResult();
+ // Open settings → integrations after the app has initialised.
+ function _tryOpen() {
+ if (window.settingsModule && typeof window.settingsModule.open === 'function') {
+ window.settingsModule.open('integrations');
+ // Brief toast-style banner.
+ const banner = document.createElement('div');
+ banner.textContent = success
+ ? '✓ Google account connected — email is ready'
+ : `Google OAuth failed: ${errMsg || 'unknown error'}`;
+ Object.assign(banner.style, {
+ position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
+ background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
+ color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
+ fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
+ boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
+ });
+ document.body.appendChild(banner);
+ setTimeout(() => banner.remove(), 4000);
+ } else {
+ setTimeout(_tryOpen, 100);
+ }
}
+ _tryOpen();
})();
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
diff --git a/static/js/skills.js b/static/js/skills.js
index b45403570..84974d446 100644
--- a/static/js/skills.js
+++ b/static/js/skills.js
@@ -83,9 +83,11 @@ export async function loadSkills(cascade = false) {
// Play the domino-in entrance on this load (set when the tab is opened,
// not for the silent re-loads after an edit/delete).
if (cascade) _cascadeNext = true;
- // Always re-fetch when the tab is explicitly opened — the cascade
- // animation is handled inside renderSkillsList() via _cascadeNext.
- // Skipping the fetch here caused stale data on panel close/reopen (#5870).
+ if (cascade && loaded && !_loadPromise && _playSkillsCascade()) {
+ _cascadeNext = false;
+ updateCount();
+ return;
+ }
if (_loadPromise) return _loadPromise;
_loadPromise = (async () => {
try {
diff --git a/tests/test_api_chat_security.py b/tests/test_api_chat_security.py
index d92a31620..7dcec324e 100644
--- a/tests/test_api_chat_security.py
+++ b/tests/test_api_chat_security.py
@@ -76,7 +76,7 @@ def _load_webhook_routes_for_test(monkeypatch):
module_name = "routes.webhook_routes_under_test"
spec = importlib.util.spec_from_file_location(
module_name,
- Path(__file__).resolve().parent.parent / "routes" / "webhook" / "webhook_routes.py",
+ Path(__file__).resolve().parent.parent / "routes" / "webhook_routes.py",
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
diff --git a/tests/test_backup_import_cross_user_dedup.py b/tests/test_backup_import_cross_user_dedup.py
index 135be78ee..2df5936ef 100644
--- a/tests/test_backup_import_cross_user_dedup.py
+++ b/tests/test_backup_import_cross_user_dedup.py
@@ -27,9 +27,6 @@ def _setup(monkeypatch, store, user="alice"):
mem = MagicMock()
mem.load_all.return_value = list(store)
- # import_data reads through the strict loader so a store it cannot read is
- # never overwritten (#5673); the double has to offer the same entry point.
- mem.load_all_for_update.return_value = list(store)
saved = {}
mem.save.side_effect = lambda entries: saved.__setitem__("entries", entries)
diff --git a/tests/test_composer_arrow_up_recall_js.py b/tests/test_composer_arrow_up_recall_js.py
index 022fcbc02..eadc3bc94 100644
--- a/tests/test_composer_arrow_up_recall_js.py
+++ b/tests/test_composer_arrow_up_recall_js.py
@@ -306,24 +306,3 @@ def test_integration_recalls_from_chat_history_dom():
)
assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout.strip()) == {"value": "stored prompt", "prevented": True}
-
-
-def test_prompt_recall_is_not_duplicated_in_app_js():
- """Only composerArrowUpRecall.js may own ArrowUp on #message (issue #5862).
-
- static/app.js once carried a near-verbatim copy of this recall logic, wired
- as a second capture-phase listener on the same textarea. That copy lacked
- the draft guard here, and because it called stopImmediatePropagation it won
- regardless of registration order — so a typed multi-line prompt was replaced
- by the last sent one instead of the caret moving up a line.
- """
- app_js = (_REPO / "static" / "app.js").read_text(encoding="utf-8")
- for marker in (
- "_odysseusPromptRecallCapture",
- "_readComposerPromptHistory",
- "odysseusRecallIndex",
- ):
- assert marker not in app_js, (
- f"static/app.js reintroduces prompt recall ({marker!r}); "
- "it belongs to static/js/composerArrowUpRecall.js alone"
- )
diff --git a/tests/test_document_routes_shim.py b/tests/test_document_routes_shim.py
deleted file mode 100644
index 68d049a62..000000000
--- a/tests/test_document_routes_shim.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""Regression test for the document route shim (slice 2m, #4082/#4071).
-
-The backward-compat shims at ``routes/document_routes.py`` and
-``routes/document_helpers.py`` use ``sys.modules`` replacement so the legacy
-import paths and the canonical ``routes.document.*`` paths resolve to the
-*same* module objects. This is required because multiple tests do
-``import routes.document_routes as droutes`` followed by
-``droutes.SessionLocal = ...`` / ``monkeypatch.setattr(droutes, ...)`` and
-``sys.modules.pop("routes.document_helpers")`` + re-import — for those to
-take effect at runtime, the legacy and canonical module objects must be
-identical.
-"""
-
-import importlib
-
-import routes.document_routes as _shim_routes # noqa: F401
-import routes.document_helpers as _shim_helpers # noqa: F401
-
-
-def test_legacy_and_canonical_routes_are_same_object():
- legacy = importlib.import_module("routes.document_routes")
- canonical = importlib.import_module("routes.document.document_routes")
- assert legacy is canonical
-
-
-def test_legacy_and_canonical_helpers_are_same_object():
- legacy = importlib.import_module("routes.document_helpers")
- canonical = importlib.import_module("routes.document.document_helpers")
- assert legacy is canonical
diff --git a/tests/test_email_oauth_connect_smtp_security.py b/tests/test_email_oauth_connect_smtp_security.py
deleted file mode 100644
index 21c4224a6..000000000
--- a/tests/test_email_oauth_connect_smtp_security.py
+++ /dev/null
@@ -1,15 +0,0 @@
-"""Regression coverage for SMTP security saved before Google OAuth."""
-
-from pathlib import Path
-
-
-_REPO = Path(__file__).resolve().parents[1]
-
-
-def test_email_tab_oauth_connect_persists_selected_smtp_security():
- source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
- start = source.index("el('eaf-oauth-btn').addEventListener")
- handler_body = source[start:source.index("if (!body.name)", start)]
-
- assert "smtp_security: el('eaf-smtp-security').value" in handler_body
- assert "display_name: el('eaf-display-name').value.trim()" in handler_body
diff --git a/tests/test_email_oauth_settings_redirect.py b/tests/test_email_oauth_settings_redirect.py
deleted file mode 100644
index f7d588132..000000000
--- a/tests/test_email_oauth_settings_redirect.py
+++ /dev/null
@@ -1,19 +0,0 @@
-"""Regression coverage for the settings UI after Google OAuth redirects."""
-
-from pathlib import Path
-
-
-_REPO = Path(__file__).resolve().parents[1]
-
-
-def test_oauth_redirect_uses_the_module_local_settings_api():
- source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
- handler = source[
- source.index("(function _handleOauthRedirect"):
- source.index("const settingsModule =")
- ]
-
- assert "open('integrations');" in handler
- assert "window.settingsModule" not in handler
- assert "window.__odysseusAppStarted" not in handler
- assert "document.addEventListener('DOMContentLoaded', _showResult, { once: true })" in handler
diff --git a/tests/test_email_summary_error_ui_js.py b/tests/test_email_summary_error_ui_js.py
deleted file mode 100644
index 1afc3bec9..000000000
--- a/tests/test_email_summary_error_ui_js.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import json
-import shutil
-import subprocess
-from pathlib import Path
-
-import pytest
-
-
-_REPO = Path(__file__).resolve().parent.parent
-_UTILS = (_REPO / "static" / "js" / "emailLibrary" / "utils.js").as_posix()
-_HAS_NODE = shutil.which("node") is not None
-
-pytestmark = pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
-
-
-def test_email_summary_renderer_ignores_untrusted_provider_error_text():
- secret = (
- "endpoint=https://private.example.internal/v1 provider=ollama "
- "model=private-model response_body=private-response "
- "Authorization: Bearer token-secret-value"
- )
- script = f"""
- import {{ _renderEmailSummaryError }} from '{_UTILS}';
- const host = {{
- ownerDocument: {{
- createElement() {{ return {{ style: {{}}, textContent: '' }}; }},
- }},
- replaceChildren(node) {{ this.child = node; }},
- }};
- _renderEmailSummaryError(host, {{
- error_code: 'email_summary_unavailable',
- error: {json.dumps(secret)},
- }});
- console.log(JSON.stringify({{
- text: host.child.textContent,
- color: host.child.style.color,
- }}));
- """
-
- proc = subprocess.run(
- ["node", "--input-type=module"],
- input=script,
- capture_output=True,
- text=True,
- cwd=str(_REPO),
- timeout=30,
- )
-
- assert proc.returncode == 0, proc.stderr
- rendered = json.loads(proc.stdout)
- assert rendered == {"text": "Failed to summarize", "color": "var(--red)"}
- assert secret not in proc.stdout
diff --git a/tests/test_email_summary_llm.py b/tests/test_email_summary_llm.py
deleted file mode 100644
index b0ab7b3be..000000000
--- a/tests/test_email_summary_llm.py
+++ /dev/null
@@ -1,406 +0,0 @@
-import asyncio
-import json
-import logging
-import os
-import sqlite3
-import sys
-import tempfile
-from pathlib import Path
-
-import pytest
-
-
-_TMP_DATA = Path(tempfile.mkdtemp(prefix="odysseus-email-summary-"))
-os.environ.setdefault("DATA_DIR", str(_TMP_DATA))
-os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TMP_DATA / 'app.db'}")
-
-PROJECT_ROOT = Path(__file__).resolve().parent.parent
-if str(PROJECT_ROOT) not in sys.path:
- sys.path.insert(0, str(PROJECT_ROOT))
-
-
-def _route_endpoint(router, path: str, method: str):
- method = method.upper()
- for route in router.routes:
- if route.path == path and method in getattr(route, "methods", set()):
- return route.endpoint
- raise AssertionError(f"route not found: {method} {path}")
-
-
-@pytest.mark.asyncio
-async def test_generate_email_summary_uses_shared_llm_adapter(monkeypatch):
- import routes.email_helpers as email_helpers
- import src.llm_core as llm_core
-
- calls = {}
-
- async def fake_llm_call_async(url, model, messages, **kwargs):
- calls["url"] = url
- calls["model"] = model
- calls["messages"] = messages
- calls["kwargs"] = kwargs
- return "thinking before marker\n<<>>\n- Pay the invoice by Friday.\n<<>>"
-
- monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call_async)
-
- summary = await email_helpers._generate_email_summary(
- url="https://chatgpt.com/backend-api/codex/responses",
- model="gpt-5.5",
- sender="Billing ",
- subject="Invoice due",
- body_for_llm="Please pay invoice 123 by Friday.",
- headers={"Authorization": "Bearer test"},
- max_tokens=1234,
- timeout=45,
- )
-
- assert summary == "- Pay the invoice by Friday."
- assert calls["url"] == "https://chatgpt.com/backend-api/codex/responses"
- assert calls["model"] == "gpt-5.5"
- assert calls["kwargs"]["headers"] == {"Authorization": "Bearer test"}
- assert calls["kwargs"]["temperature"] == 0.3
- assert calls["kwargs"]["max_tokens"] == 1234
- assert calls["kwargs"]["timeout"] == 45
- assert calls["kwargs"]["workload"] == "foreground"
- assert calls["messages"][0]["role"] == "system"
- assert calls["messages"][1]["role"] == "user"
-
-
-@pytest.mark.asyncio
-async def test_scheduled_email_summary_uses_background_fallback_chain(monkeypatch):
- import routes.email_helpers as email_helpers
- import src.llm_core as llm_core
- import src.task_endpoint as task_endpoint
-
- candidates = [
- ("http://primary.invalid/v1", "primary-model", {"X-Candidate": "primary"}),
- ("http://fallback.invalid/v1", "fallback-model", {"X-Candidate": "fallback"}),
- ]
- resolve_calls = []
- wait_calls = []
- llm_calls = []
-
- def fake_resolve_task_candidates(**kwargs):
- resolve_calls.append(kwargs)
- return candidates
-
- async def fake_wait_for_interactive_quiet(label):
- wait_calls.append(label)
- return False
-
- async def fake_llm_call_async(url, model, messages, **kwargs):
- llm_calls.append((url, model, messages, kwargs))
- if model == "primary-model":
- raise RuntimeError("primary unavailable")
- return "<<>>\n- Used the fallback model.\n<<>>"
-
- monkeypatch.setattr(task_endpoint, "resolve_task_candidates", fake_resolve_task_candidates)
- monkeypatch.setattr(task_endpoint, "wait_for_interactive_quiet", fake_wait_for_interactive_quiet)
- monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call_async)
-
- summary = await email_helpers._generate_scheduled_email_summary(
- url="http://caller-fallback.invalid/v1",
- model="caller-fallback-model",
- sender="Sender ",
- subject="Scheduled subject",
- body_for_llm="Please summarize this scheduled email.",
- headers={"Authorization": "Bearer test"},
- owner="alice",
- max_tokens=321,
- timeout=54,
- )
-
- assert summary == "- Used the fallback model."
- assert resolve_calls == [{
- "fallback_url": "http://caller-fallback.invalid/v1",
- "fallback_model": "caller-fallback-model",
- "fallback_headers": {"Authorization": "Bearer test"},
- "owner": "alice",
- }]
- assert wait_calls == ["background task LLM"]
- assert [call[1] for call in llm_calls] == ["primary-model", "fallback-model"]
- assert all(call[3]["workload"] == "background" for call in llm_calls)
- assert all(call[3]["max_tokens"] == 321 for call in llm_calls)
- assert all(call[3]["timeout"] == 54 for call in llm_calls)
-
-
-@pytest.mark.asyncio
-async def test_scheduled_local_summary_is_preempted_by_foreground_call(monkeypatch):
- import routes.email_helpers as email_helpers
- import src.llm_core as llm_core
- import src.task_endpoint as task_endpoint
-
- local_url = "http://127.0.0.1:11434/v1/chat/completions"
- background_started = asyncio.Event()
- never_release = asyncio.Event()
- observed_workloads = []
-
- monkeypatch.setenv("ODYSSEUS_LOCAL_MODEL_GATE", "true")
- monkeypatch.setenv("BACKGROUND_TASK_FOREGROUND_GATE", "false")
- monkeypatch.setattr(llm_core, "_LOCAL_MODEL_LOCK", asyncio.Lock())
- monkeypatch.setattr(llm_core, "_LOCAL_MODEL_CURRENT", {})
- monkeypatch.setattr(llm_core, "_LOCAL_MODEL_WAITING_FOREGROUND", 0)
- monkeypatch.setattr(
- task_endpoint,
- "resolve_task_candidates",
- lambda **_kwargs: [(local_url, "scheduled-model", {})],
- )
-
- async def fake_wait_for_interactive_quiet(_label):
- return False
-
- async def gated_llm_call(url, model, messages, **kwargs):
- assert messages
- workload = kwargs.get("workload")
- observed_workloads.append(workload)
- async with llm_core._local_model_slot(url, model, workload=workload):
- background_started.set()
- await never_release.wait()
- return "unreachable"
-
- monkeypatch.setattr(task_endpoint, "wait_for_interactive_quiet", fake_wait_for_interactive_quiet)
- monkeypatch.setattr(llm_core, "llm_call_async", gated_llm_call)
-
- background_task = asyncio.create_task(email_helpers._generate_scheduled_email_summary(
- url=local_url,
- model="scheduled-model",
- sender="Sender",
- subject="Scheduled",
- body_for_llm="Scheduled body",
- owner="alice",
- ))
- foreground_task = None
- try:
- await asyncio.wait_for(background_started.wait(), timeout=1)
-
- async def run_foreground():
- async with llm_core._local_model_slot(
- local_url,
- "interactive-model",
- workload="foreground",
- ):
- return True
-
- foreground_task = asyncio.create_task(run_foreground())
- with pytest.raises(asyncio.CancelledError):
- await asyncio.wait_for(background_task, timeout=1)
- assert await asyncio.wait_for(foreground_task, timeout=1) is True
- assert observed_workloads == ["background"]
- finally:
- for task in (background_task, foreground_task):
- if task is not None and not task.done():
- task.cancel()
-
-
-@pytest.mark.asyncio
-async def test_manual_email_summary_uses_shared_helper_and_caches(tmp_path, monkeypatch):
- import routes.email_helpers as email_helpers
- import routes.email_routes as email_routes
- import src.endpoint_resolver as endpoint_resolver
-
- db_path = tmp_path / "scheduled_emails.db"
- monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
- monkeypatch.setattr(email_routes, "SCHEDULED_DB", db_path)
- email_helpers._init_scheduled_db()
-
- resolve_calls = []
-
- def fake_resolve_endpoint(kind, owner=None):
- resolve_calls.append((kind, owner))
- assert kind == "utility"
- assert owner == "alice"
- return (
- "https://chatgpt.com/backend-api/codex/responses",
- "gpt-5.5",
- {"Authorization": "Bearer test"},
- )
-
- helper_calls = {}
-
- async def fake_generate_email_summary(**kwargs):
- helper_calls.update(kwargs)
- return "- Manual summary"
-
- monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
- monkeypatch.setattr(email_routes, "_generate_email_summary", fake_generate_email_summary)
-
- router = email_routes.setup_email_routes()
- summarize = _route_endpoint(router, "/api/email/summarize", "POST")
-
- result = await summarize(
- {
- "body": "This is a long enough email body for manual summary.",
- "subject": "Manual subject",
- "from": "Sender ",
- "message_id": "",
- "folder": "INBOX",
- },
- owner="alice",
- )
-
- assert result == {
- "success": True,
- "summary": "- Manual summary",
- "model_used": "gpt-5.5",
- }
- assert resolve_calls == [("utility", "alice")]
- assert helper_calls["url"] == "https://chatgpt.com/backend-api/codex/responses"
- assert helper_calls["model"] == "gpt-5.5"
- assert helper_calls["headers"]["Authorization"] == "Bearer test"
- assert helper_calls["headers"]["Content-Type"] == "application/json"
-
- conn = sqlite3.connect(db_path)
- try:
- row = conn.execute(
- "SELECT owner, summary, model_used FROM email_summaries WHERE message_id=?",
- ("",),
- ).fetchone()
- finally:
- conn.close()
- assert row == ("alice", "- Manual summary", "gpt-5.5")
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize("exception_kind", ["http", "runtime"])
-async def test_manual_email_summary_never_exposes_provider_exception(
- monkeypatch,
- caplog,
- exception_kind,
-):
- from fastapi import HTTPException
- import routes.email_routes as email_routes
- import src.endpoint_resolver as endpoint_resolver
-
- secret_detail = (
- "endpoint=https://private.example.internal/v1 provider=ollama "
- "model=private-model response_body=private-response "
- "Authorization: Bearer token-secret-value"
- )
-
- def fake_resolve_endpoint(kind, owner=None):
- assert kind == "utility"
- assert owner == "alice"
- return (
- "https://private.example.internal/v1",
- "private-model",
- {"Authorization": "Bearer token-secret-value"},
- )
-
- async def fail_summary(**_kwargs):
- if exception_kind == "http":
- raise HTTPException(status_code=502, detail=secret_detail)
- raise RuntimeError(secret_detail)
-
- monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
- monkeypatch.setattr(email_routes, "_generate_email_summary", fail_summary)
- caplog.set_level(logging.WARNING, logger=email_routes.__name__)
-
- router = email_routes.setup_email_routes()
- summarize = _route_endpoint(router, "/api/email/summarize", "POST")
- result = await summarize(
- {
- "body": "This email body is long enough to summarize.",
- "subject": "Sensitive provider failure",
- "from": "Sender ",
- },
- owner="alice",
- )
-
- assert result == {
- "success": False,
- "error": "Failed to summarize",
- "error_code": "email_summary_unavailable",
- }
- exposed = json.dumps(result) + caplog.text
- for marker in (
- "private.example.internal",
- "ollama",
- "private-model",
- "private-response",
- "token-secret-value",
- ):
- assert marker not in exposed
- assert f"type={'HTTPException' if exception_kind == 'http' else 'RuntimeError'}" in caplog.text
-
-
-@pytest.mark.asyncio
-async def test_scheduled_email_summary_uses_shared_helper_and_caches(tmp_path, monkeypatch):
- import routes.email_helpers as email_helpers
- import routes.email_pollers as email_pollers
-
- db_path = tmp_path / "scheduled_emails.db"
- monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
- monkeypatch.setattr(email_pollers, "SCHEDULED_DB", db_path)
- email_helpers._init_scheduled_db()
-
- raw_email = (
- b"From: Sender \r\n"
- b"To: Alice \r\n"
- b"Subject: Scheduled subject\r\n"
- b"Message-ID: \r\n"
- b"Date: Tue, 01 Jan 2026 12:00:00 +0000\r\n"
- b"Content-Type: text/plain; charset=utf-8\r\n"
- b"\r\n"
- + (b"Please review this scheduled summary email. " * 8)
- )
-
- class FakeImap:
- def __init__(self):
- self.logout_calls = 0
-
- def select(self, _folder, readonly=True):
- return "OK", []
-
- def uid(self, command, *args):
- if command == "SEARCH":
- return "OK", [b"1"]
- if command == "FETCH":
- return "OK", [(b"1 (RFC822)", raw_email)]
- raise AssertionError(f"unexpected uid command: {command!r} {args!r}")
-
- def logout(self):
- self.logout_calls += 1
-
- fake_conn = FakeImap()
-
- def fake_resolve_task_candidates(owner=None):
- assert owner == "alice"
- return [(
- "https://chatgpt.com/backend-api/codex/responses",
- "gpt-5.5",
- {"Authorization": "Bearer test"},
- )]
-
- helper_calls = {}
-
- async def fake_generate_email_summary(**kwargs):
- helper_calls.update(kwargs)
- return "- Scheduled summary"
-
- monkeypatch.setattr(email_pollers, "_load_settings", lambda: {"email_auto_summarize": True})
- monkeypatch.setattr(email_pollers, "_owner_for_email_account", lambda _account_id: "alice")
- monkeypatch.setattr(email_pollers, "_imap_connect", lambda account_id=None, owner="": fake_conn)
- monkeypatch.setattr(email_pollers, "_get_email_config", lambda account_id=None, owner="": {"from_address": "alice@example.com"})
- monkeypatch.setattr(email_pollers, "resolve_task_candidates", fake_resolve_task_candidates)
- monkeypatch.setattr(email_pollers, "_generate_scheduled_email_summary", fake_generate_email_summary)
-
- result = await email_pollers._auto_summarize_pass_single(account_id="acct-alice")
-
- assert "summarized 1" in result
- assert "summary failed" not in result
- assert helper_calls["url"] == "https://chatgpt.com/backend-api/codex/responses"
- assert helper_calls["model"] == "gpt-5.5"
- assert helper_calls["headers"]["Authorization"] == "Bearer test"
- assert helper_calls["headers"]["Content-Type"] == "application/json"
- assert helper_calls["owner"] == "alice"
- assert fake_conn.logout_calls == 1
-
- conn = sqlite3.connect(db_path)
- try:
- row = conn.execute(
- "SELECT owner, summary, model_used FROM email_summaries WHERE message_id=?",
- ("",),
- ).fetchone()
- finally:
- conn.close()
- assert row == ("alice", "- Scheduled summary", "gpt-5.5")
diff --git a/tests/test_imap_mailbox_quoting.py b/tests/test_imap_mailbox_quoting.py
index 636270a56..7c5bb1645 100644
--- a/tests/test_imap_mailbox_quoting.py
+++ b/tests/test_imap_mailbox_quoting.py
@@ -87,7 +87,7 @@ def test_known_imap_mailbox_call_sites_are_quoted():
assert "conn.select(sent_name" not in pollers
assert "imap.append(sent_folder" not in pollers
- document_routes = Path("routes/document/document_routes.py").read_text()
+ document_routes = Path("routes/document_routes.py").read_text()
assert "conn.select(doc.source_email_folder" not in document_routes
diff --git a/tests/test_integration_api_call_ssrf.py b/tests/test_integration_api_call_ssrf.py
index f23cc40de..53dc671c5 100644
--- a/tests/test_integration_api_call_ssrf.py
+++ b/tests/test_integration_api_call_ssrf.py
@@ -9,13 +9,8 @@ link-local/metadata is always rejected; RFC-1918/loopback only when
INTEGRATION_API_BLOCK_PRIVATE_IPS=true (LAN integrations are the primary
use case, so private stays allowed by default).
"""
-import asyncio
-import ipaddress
-import ssl
from unittest.mock import AsyncMock, MagicMock, patch
-import httpcore
-import httpx
import pytest
from src import integrations
@@ -102,238 +97,3 @@ async def test_private_base_url_allowed_by_default_blocked_with_knob(monkeypatch
assert result["exit_code"] == 1
assert "rejected" in result["error"].lower()
client.request.assert_not_called()
-
-
-async def _call_capturing_transport(base_url, path="/items"):
- """Drive execute_api_call and return (result, transport) where transport is
- the object passed to httpx.AsyncClient(transport=...)."""
- resp = MagicMock()
- resp.status_code = 200
- resp.headers = {"content-type": "application/json"}
- resp.json.return_value = {"ok": True}
- resp.text = '{"ok": true}'
-
- client = AsyncMock()
- client.__aenter__ = AsyncMock(return_value=client)
- client.__aexit__ = AsyncMock(return_value=None)
- client.request = AsyncMock(return_value=resp)
-
- captured = {}
-
- def _fake_async_client(*args, **kwargs):
- captured.update(kwargs)
- return client
-
- with (
- patch.object(integrations, "_find_integration",
- return_value=_integration(base_url)),
- patch("httpx.AsyncClient", side_effect=_fake_async_client),
- ):
- result = await integrations.execute_api_call("test_integ", "GET", path)
- return result, captured.get("transport"), client
-
-
-@pytest.mark.asyncio
-async def test_connection_is_pinned_to_the_validated_ip(monkeypatch):
- """DNS-rebinding defense: the guard resolves the host once to a benign
- public IP, and the request must be pinned to *that* IP so a host that
- rebinds to the metadata range at connect time can't be reached with the
- integration's auth headers. Static resolution passing the guard is not
- enough — a plain client would re-resolve at connect."""
- monkeypatch.setattr("src.url_safety._default_resolver",
- lambda host: ["93.184.216.34"])
- result, transport, client = await _call_capturing_transport(
- "http://rebinding.attacker.example")
-
- assert result.get("exit_code") == 0
- client.request.assert_called_once()
- assert isinstance(transport, integrations._PinnedAsyncTransport)
- assert [str(ip) for ip in transport._pinned_ips] == ["93.184.216.34"]
-
-
-@pytest.mark.asyncio
-async def test_pin_carries_the_whole_validated_ip_set(monkeypatch):
- """When a host resolves to several records the transport keeps all of them
- (check_outbound_url validated every one), in resolver order, so it can fall
- back past a dead first address instead of failing the whole call."""
- monkeypatch.setattr("src.url_safety._default_resolver",
- lambda host: ["93.184.216.34", "198.51.100.7"])
- result, transport, _ = await _call_capturing_transport("http://multi.example")
-
- assert result.get("exit_code") == 0
- assert [str(ip) for ip in transport._pinned_ips] == ["93.184.216.34", "198.51.100.7"]
-
-
-class _FakeStream:
- """Stand-in for the connected socket the real backend returns."""
-
-
-class _RecordingBackend:
- """Fake httpcore backend: connect_tcp fails for the addresses in `dead`
- and succeeds for the rest, recording the order it was asked to connect."""
-
- def __init__(self, dead):
- self.dead = set(dead)
- self.attempts = []
-
- async def connect_tcp(self, host, port, timeout=None, local_address=None,
- socket_options=None):
- self.attempts.append((host, timeout))
- if host in self.dead:
- raise httpcore.ConnectError(f"connection refused: {host}")
- return _FakeStream()
-
-
-def _pinned_backend(ips, dead):
- """A _PinnedAsyncBackend whose underlying connect is the recording fake."""
- backend = integrations._PinnedAsyncBackend(ips)
- backend._real = _RecordingBackend(dead)
- return backend
-
-
-@pytest.mark.asyncio
-async def test_connect_falls_back_from_dead_first_to_live_second():
- """first-dead / second-live: the pinned backend must try the next validated
- address when the first refuses, rather than surfacing the failure. It also
- ignores the `host` httpcore passes (the original hostname) and connects to
- the pinned IPs, which is what keeps TLS SNI / Host on the real hostname."""
- ips = [ipaddress.ip_address("203.0.113.10"), ipaddress.ip_address("198.51.100.7")]
- backend = _pinned_backend(ips, dead={"203.0.113.10"})
-
- stream = await backend.connect_tcp("original.hostname.example", 443, timeout=5.0)
-
- assert isinstance(stream, _FakeStream)
- # Tried the dead address first, then the live one — never the hostname.
- assert [host for host, _ in backend._real.attempts] == ["203.0.113.10", "198.51.100.7"]
- # Fallback shared one budget: the second attempt got the time left, not a fresh 5s.
- assert backend._real.attempts[1][1] <= 5.0
-
-
-@pytest.mark.asyncio
-async def test_connect_raises_when_every_validated_address_is_dead():
- ips = [ipaddress.ip_address("203.0.113.10"), ipaddress.ip_address("198.51.100.7")]
- backend = _pinned_backend(ips, dead={"203.0.113.10", "198.51.100.7"})
-
- with pytest.raises(httpcore.ConnectError):
- await backend.connect_tcp("original.hostname.example", 443, timeout=5.0)
- assert [host for host, _ in backend._real.attempts] == ["203.0.113.10", "198.51.100.7"]
-
-
-@pytest.mark.asyncio
-async def test_pinned_transport_reuses_httpx_ca_trust(monkeypatch):
- """TLS trust must come from the same builder the default httpx client uses
- (certifi + SSL_CERT_FILE / SSL_CERT_DIR via trust_env), not from
- ssl.create_default_context()'s system roots — otherwise chains that verified
- under the old default client can silently stop verifying."""
- sentinel = ssl.create_default_context()
- calls = []
-
- def _fake_create(*args, **kwargs):
- calls.append(kwargs)
- return sentinel
-
- monkeypatch.setattr(httpx, "create_ssl_context", _fake_create)
- transport = integrations._PinnedAsyncTransport([ipaddress.ip_address("93.184.216.34")])
- try:
- assert calls, "transport did not build its context via httpx.create_ssl_context"
- assert transport._pool._ssl_context is sentinel
- finally:
- await transport.aclose()
-
-
-@pytest.mark.asyncio
-async def test_real_socket_falls_back_from_dead_first_to_live_second():
- """End-to-end over real loopback sockets: pin [127.0.0.2 (nothing
- listening), 127.0.0.1 (live)], and the request must succeed by falling back
- to the second address while the Host header stays the original hostname —
- i.e. only the socket destination moved, vhost/SNI routing did not."""
- captured = {}
-
- async def handle(reader, writer):
- request = await reader.read(4096)
- for line in request.split(b"\r\n"):
- if line.lower().startswith(b"host:"):
- captured["host"] = line.split(b":", 1)[1].strip().decode()
- writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi")
- await writer.drain()
- writer.close()
-
- server = await asyncio.start_server(handle, "127.0.0.1", 0)
- port = server.sockets[0].getsockname()[1]
- async with server:
- await server.start_serving()
- transport = integrations._PinnedAsyncTransport(
- [ipaddress.ip_address("127.0.0.2"), ipaddress.ip_address("127.0.0.1")]
- )
- try:
- async with httpx.AsyncClient(transport=transport) as client:
- resp = await client.get(f"http://pinned.example:{port}/health")
- finally:
- await transport.aclose()
-
- assert resp.status_code == 200
- assert resp.text == "hi"
- assert captured.get("host") == f"pinned.example:{port}"
-
-
-@pytest.mark.asyncio
-async def test_ip_literal_base_url_still_pins_and_is_not_rejected():
- """A base_url that is already an IP has nothing to rebind, but it must not
- trip the "did not resolve" guard either.
-
- check_outbound_url resolves even a literal (getaddrinfo returns the address
- itself), so the captured list is populated and the pin is a no-op rather
- than a rejection. Uses the real resolver on purpose — no monkeypatch — so
- this would catch the fail-closed branch firing on a literal.
- """
- result, transport, client = await _call_capturing_transport(
- "http://93.184.216.34")
-
- assert result.get("exit_code") == 0
- assert isinstance(transport, integrations._PinnedAsyncTransport)
- assert [str(ip) for ip in transport._pinned_ips] == ["93.184.216.34"]
-
-
-@pytest.mark.asyncio
-async def test_ipv6_base_url_pins_every_validated_address(monkeypatch):
- """IPv6 goes down the same path as v4.
-
- Resolution is stubbed rather than using a literal so this doesn't depend on
- the runner having IPv6 configured.
- """
- v6 = "2606:2800:220:1:248:1893:25c8:1946"
- monkeypatch.setattr("src.url_safety._default_resolver", lambda host: [v6])
- result, transport, client = await _call_capturing_transport("http://v6.example")
-
- assert result.get("exit_code") == 0
- assert isinstance(transport, integrations._PinnedAsyncTransport)
- assert [str(ip) for ip in transport._pinned_ips] == [v6]
-
-
-def test_validated_ips_strips_zone_id_and_drops_junk():
- """getaddrinfo can hand back a scoped v6 address like 'fe80::1%eth0'."""
- got = integrations._validated_ips(
- ["93.184.216.34", "fe80::1%eth0", "not-an-ip", None, "2001:db8::5"]
- )
- assert [str(ip) for ip in got] == ["93.184.216.34", "fe80::1", "2001:db8::5"]
-
-
-def test_validated_ips_deduplicates_repeated_addresses():
- """The resolver is getaddrinfo(host, None) with no socktype filter, so glibc
- returns one record per socktype and a single-homed host arrives three times
- over. Duplicates must collapse (first-seen order kept) or the connect
- fallback wastes its shared deadline retrying one dead address."""
- got = integrations._validated_ips(
- ["93.184.216.34", "93.184.216.34", "93.184.216.34"]
- )
- assert [str(ip) for ip in got] == ["93.184.216.34"]
-
- # Order is first-seen, and distinct addresses all survive.
- got = integrations._validated_ips(
- ["198.51.100.7", "93.184.216.34", "198.51.100.7", "2001:db8::5"]
- )
- assert [str(ip) for ip in got] == ["198.51.100.7", "93.184.216.34", "2001:db8::5"]
-
- # A zone-id variant is the same address once stripped, so it collapses too.
- got = integrations._validated_ips(["fe80::1%eth0", "fe80::1%eth1", "fe80::1"])
- assert [str(ip) for ip in got] == ["fe80::1"]
diff --git a/tests/test_integrations_api_call_truncation.py b/tests/test_integrations_api_call_truncation.py
index a0ad61b4a..bf1ec7d05 100644
--- a/tests/test_integrations_api_call_truncation.py
+++ b/tests/test_integrations_api_call_truncation.py
@@ -83,10 +83,9 @@ async def _call(json_data, status=200):
with (
patch.object(integrations, "_find_integration", return_value=DUMMY_INTEGRATION),
patch("httpx.AsyncClient", return_value=mock_client),
- # api.example.com doesn't resolve. Point the resolver at a public
- # address instead of stubbing the guard open, so the real check (and
- # the connect-IP pinning that reads its result) still runs.
- patch("src.url_safety._default_resolver", lambda host: ["93.184.216.34"]),
+ # api.example.com doesn't resolve; the SSRF guard would fail closed.
+ # These tests are about truncation, so stub the guard open.
+ patch("src.url_safety.check_outbound_url", return_value=(True, "ok")),
):
return await integrations.execute_api_call("test_integ", "GET", "/items")
@@ -102,10 +101,9 @@ async def _call_with_integration(integration, path="/items"):
with (
patch.object(integrations, "_find_integration", return_value=integration),
patch("httpx.AsyncClient", return_value=mock_client),
- # api.example.com doesn't resolve. Point the resolver at a public
- # address instead of stubbing the guard open, so the real check (and
- # the connect-IP pinning that reads its result) still runs.
- patch("src.url_safety._default_resolver", lambda host: ["93.184.216.34"]),
+ # api.example.com doesn't resolve; the SSRF guard would fail closed.
+ # These tests are about URL joining, so stub the guard open.
+ patch("src.url_safety.check_outbound_url", return_value=(True, "ok")),
):
result = await integrations.execute_api_call("test_integ", "GET", path)
return result, mock_client
diff --git a/tests/test_issue_description_check.py b/tests/test_issue_description_check.py
deleted file mode 100644
index 196f21cfc..000000000
--- a/tests/test_issue_description_check.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""Regression coverage for issue-description label lifecycle events."""
-
-import json
-import shutil
-import subprocess
-from pathlib import Path
-
-import pytest
-
-
-_REPO = Path(__file__).resolve().parent.parent
-_CHECKER = _REPO / ".github" / "scripts" / "check-issue-description.js"
-_WORKFLOW = _REPO / ".github" / "workflows" / "issue-description-check.yml"
-pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
-
-
-def _run_closed_issue(action):
- harness = r"""
-const checkIssueDescription = require(process.argv[1]);
-const action = process.argv[2];
-const calls = [];
-const unexpected = (name) => async () => {
- throw new Error(`${name} should not be called for a closed issue`);
-};
-
-const github = {
- rest: {
- issues: {
- removeLabel: async (params) => calls.push({ method: 'removeLabel', params }),
- getLabel: unexpected('getLabel'),
- addLabels: unexpected('addLabels'),
- listComments: unexpected('listComments'),
- createComment: unexpected('createComment'),
- updateComment: unexpected('updateComment'),
- deleteComment: unexpected('deleteComment'),
- },
- },
-};
-const context = {
- payload: {
- action,
- issue: { number: 42, state: 'closed', body: '', labels: [] },
- },
- repo: { owner: 'odysseus-dev', repo: 'odysseus' },
-};
-const core = {
- warning: unexpected('core.warning'),
- setFailed: unexpected('core.setFailed'),
-};
-
-checkIssueDescription({ github, context, core })
- .then(() => process.stdout.write(JSON.stringify(calls)))
- .catch((error) => {
- console.error(error);
- process.exitCode = 1;
- });
-"""
- proc = subprocess.run(
- ["node", "-e", harness, str(_CHECKER), action],
- capture_output=True,
- text=True,
- cwd=str(_REPO),
- timeout=30,
- )
- assert proc.returncode == 0, proc.stderr
- return json.loads(proc.stdout)
-
-
-def test_workflow_handles_issue_closures():
- workflow = _WORKFLOW.read_text()
- assert "types: [opened, edited, reopened, closed]" in workflow
-
-
-@pytest.mark.parametrize("action", ["closed", "edited"])
-def test_closed_issue_only_drops_ready_for_review(action):
- assert _run_closed_issue(action) == [
- {
- "method": "removeLabel",
- "params": {
- "owner": "odysseus-dev",
- "repo": "odysseus",
- "issue_number": 42,
- "name": "ready for review",
- },
- }
- ]
diff --git a/tests/test_llm_core_anthropic_temp_omit.py b/tests/test_llm_core_anthropic_temp_omit.py
index f7d26aef0..2274f1dc9 100644
--- a/tests/test_llm_core_anthropic_temp_omit.py
+++ b/tests/test_llm_core_anthropic_temp_omit.py
@@ -29,13 +29,6 @@ from src.llm_core import _anthropic_rejects_temperature, _build_anthropic_payloa
"anthropic/claude-opus-4-7", # tolerate a provider-prefixed id
"claude-opus-4-10", # future minor still >= 4.7
"claude-opus-5-0", # future major
- # Major-only ids: a missing minor reads as `.0`, so these are >= 4.7 too
- # (issue #5753). Before the fix the version pattern required a minor, so
- # these fell through to "accepts temperature" and every call 400'd.
- "claude-opus-5",
- "claude-opus-5-20260101", # major-only + dated snapshot
- "anthropic/claude-opus-5", # major-only behind a provider prefix
- "claude-opus-6", # future major-only
],
)
def test_opus_47_plus_rejects_temperature(model):
@@ -55,10 +48,7 @@ def test_opus_47_plus_rejects_temperature(model):
"claude-opus-4-6-20251201", # dated 4.6 snapshot — older, still keeps temperature
"claude-sonnet-4-6",
"claude-3-5-sonnet",
- "claude-3-opus-20240229", # legacy Claude 3 Opus — date directly after
- # "opus-", so the major must not swallow it as version 20240229 (that is
- # what makes capping the major at 1-2 digits necessary once the minor
- # became optional in #5753).
+ "claude-3-opus-20240229", # legacy Claude 3 Opus — no opus-N-M pattern, kept
"claude-haiku-4-5",
"claude-x",
"octopus-4-8", # "opus" only as a substring of another word — must not match
@@ -97,20 +87,6 @@ def test_payload_keeps_temperature_for_older_models():
assert _payload("claude-3-5-sonnet", 1.2)["temperature"] == 1.0
-def test_payload_omits_temperature_for_major_only_opus_5():
- # Issue #5753: the scheduled-task path calls stream_agent_loop() without a
- # temperature and inherits its 0.3 default, so `claude-opus-5` 400'd on every
- # run and surfaced as "the model returned an empty response". Interactive chat
- # leaves temperature None and never hit it.
- assert "temperature" not in _payload("claude-opus-5", 0.3)
-
-
-def test_payload_keeps_temperature_for_legacy_claude_3_opus():
- # Guards the major-digit cap: `opus-20240229` must not parse as version
- # 20240229, or Claude 3 Opus would silently lose the caller's temperature.
- assert _payload("claude-3-opus-20240229", 0.5)["temperature"] == 0.5
-
-
def test_payload_keeps_temperature_for_dated_opus_4_0():
# Anthropic's dated id for Opus 4.0 (claude-opus-4-20250514) is in this repo's
# ANTHROPIC_MODELS list. The date must not be misread as a >= 4.7 minor, or the
diff --git a/tests/test_manage_skills_action_required.py b/tests/test_manage_skills_action_required.py
deleted file mode 100644
index 4efae8026..000000000
--- a/tests/test_manage_skills_action_required.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import json
-
-import pytest
-
-from src.tools.system import do_manage_skills
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
- "payload",
- [
- {},
- {"action": ""},
- {"action": " "},
- {"name": "demo", "description": "x", "procedure": ["step"]},
- ],
-)
-async def test_manage_skills_requires_action(payload):
- result = await do_manage_skills(json.dumps(payload), owner="test")
-
- assert result == {
- "error": "action is required (list|view|view_ref|add|edit|patch|publish|delete|search)",
- "exit_code": 1,
- }
diff --git a/tests/test_markdown_rendering_js.py b/tests/test_markdown_rendering_js.py
index 536789b89..2ffe8914f 100644
--- a/tests/test_markdown_rendering_js.py
+++ b/tests/test_markdown_rendering_js.py
@@ -214,50 +214,6 @@ def test_inline_code_content_is_html_escaped(node_available):
assert "" not in html
-def test_fenced_code_keeps_dollar_ampersand(node_available):
- # Issue #5663: the block-restore pass used a string replacement, so `$&` in a
- # restored block was read as "the matched text" and re-inserted the
- # placeholder. `perl -pe 's/world/$& again/'` rendered as
- # "s/world/___CODE_BLOCK_0___amp; again/" — the trailing "amp;" is the orphan
- # left behind after `$&` consumed the `$&` of the escaped `$&`.
- html = _run_markdown_case(
- "```sh\necho \"hello world\" | perl -pe 's/world/$& again/'\n```"
- )
-
- assert "___CODE_BLOCK_" not in html
- assert "s/world/$& again/" in html
- assert "amp; again" not in html.replace("$& again", "")
-
-
-def test_fenced_code_keeps_dollar_backtick_and_quote(node_available):
- # `` $` `` and `$'` splice the text before/after the placeholder into the
- # block. Unlike `$&` these leave no placeholder behind — the characters just
- # vanish — so assert the content survives verbatim.
- html = _run_markdown_case("```sh\nsed \"s/$`/x/\" && sed \"s/$'/y/\"\n```")
-
- assert "___CODE_BLOCK_" not in html
- assert "s/$`/x/" in html
- assert "s/$'/y/" in html
-
-
-def test_fenced_code_keeps_double_dollar(node_available):
- # `$$` collapsed to a single `$` in the restored block.
- html = _run_markdown_case('```sh\necho "$$USD and $$"\n```')
-
- assert "$$USD and $$" in html
-
-
-def test_mermaid_block_keeps_dollar_ampersand(node_available):
- # The mermaid restore site had the same hazard: a node label containing `$&`
- # re-inserted the ___MERMAID_BLOCK_n___ placeholder into the diagram source,
- # which then fails to parse. The math and allowed-HTML sites are fixed the
- # same way; they need KaTeX/sanitizer conditions this harness doesn't set up.
- html = _run_markdown_case('```mermaid\ngraph TD; A["$&"] --> B;\n```')
-
- assert "___MERMAID_BLOCK_" not in html
- assert "$&" in html
-
-
def test_currency_dollar_amounts_are_not_rendered_as_math(node_available):
# "$5 to $10" used to pair the two dollar signs as inline-math delimiters
# and render "5 to" through KaTeX. Pandoc-style rules now reject it: the
diff --git a/tests/test_mcp_dependency_compatibility.py b/tests/test_mcp_dependency_compatibility.py
deleted file mode 100644
index 9efefe4fe..000000000
--- a/tests/test_mcp_dependency_compatibility.py
+++ /dev/null
@@ -1,15 +0,0 @@
-"""Regression coverage for the built-in MCP servers' SDK compatibility line."""
-
-from pathlib import Path
-
-
-REQUIREMENTS = Path(__file__).resolve().parents[1] / "requirements.txt"
-
-
-def test_mcp_requirement_excludes_breaking_v2_sdk():
- requirements = [
- line.split("#", 1)[0].strip().replace(" ", "")
- for line in REQUIREMENTS.read_text(encoding="utf-8").splitlines()
- ]
-
- assert "mcp<2" in requirements
diff --git a/tests/test_memory_add_submit_regression.py b/tests/test_memory_add_submit_regression.py
deleted file mode 100644
index 450d63003..000000000
--- a/tests/test_memory_add_submit_regression.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""The Brain > Add Memory form must be submittable (#5828).
-
-The form previously had no submit button and relied on a deprecated
-``keypress`` listener for Enter, which is not guaranteed to fire on all
-platforms — leaving the form with no working submit path. Pins:
-
-- a visible, keyboard-accessible submit button next to the category select;
-- the button wired to ``memoryModule.addNewMemory()``;
-- Enter handled via ``keydown`` with ``preventDefault()`` (and no lingering
- ``keypress`` handler on the input).
-"""
-from pathlib import Path
-
-APP_JS = Path("static/app.js")
-INDEX_HTML = Path("static/index.html")
-
-
-def _add_memory_row(html):
- start = html.index('id="new-memory-input"')
- end = html.index("
", html.index('id="new-memory-add-btn"', start))
- return html[start:end]
-
-
-def test_add_memory_form_renders_a_submit_button():
- html = INDEX_HTML.read_text()
- row = _add_memory_row(html)
-
- assert 'id="new-memory-category"' in row, "button must sit in the same row as the form fields"
- btn_start = row.index('id="new-memory-add-btn"')
- btn_tag = row[row.rindex("", btn_start)]
- assert 'type="button"' in btn_tag, "must not rely on implicit submit semantics"
-
-
-def _new_memory_wiring_block(source):
- start = source.index("const newMemoryInput = el('new-memory-input');")
- end = source.index("// Voice recording", start)
- return source[start:end]
-
-
-def test_submit_button_is_wired_to_add_new_memory():
- block = _new_memory_wiring_block(APP_JS.read_text())
-
- assert "el('new-memory-add-btn')" in block
- assert "addEventListener('click', () => memoryModule.addNewMemory())" in block
-
-
-def test_enter_uses_keydown_with_prevent_default():
- block = _new_memory_wiring_block(APP_JS.read_text())
-
- assert "addEventListener('keydown'" in block
- assert "addEventListener('keypress'" not in block, "keypress is deprecated and unreliable for Enter"
- assert "e.preventDefault();" in block
- assert "!e.isComposing" in block, "IME composition must not submit the form"
- assert "memoryModule.addNewMemory();" in block
diff --git a/tests/test_memory_extractor_vector_cross_tenant.py b/tests/test_memory_extractor_vector_cross_tenant.py
index 06ca31667..49702c17f 100644
--- a/tests/test_memory_extractor_vector_cross_tenant.py
+++ b/tests/test_memory_extractor_vector_cross_tenant.py
@@ -67,12 +67,6 @@ class FakeMemoryManager:
def load_all(self):
return list(self.rows)
- def load_all_for_update(self):
- # Mirrors the real MemoryManager: extraction is a read-modify-write and
- # goes through the strict loader (#5673). A healthy store behaves the
- # same as load_all.
- return list(self.rows)
-
def load(self, owner=None):
return [r for r in self.rows if r.get("owner") == owner]
diff --git a/tests/test_memory_store_unreadable_no_wipe.py b/tests/test_memory_store_unreadable_no_wipe.py
deleted file mode 100644
index 4b9076065..000000000
--- a/tests/test_memory_store_unreadable_no_wipe.py
+++ /dev/null
@@ -1,255 +0,0 @@
-"""A memory store that cannot be READ must never be overwritten (issue #5673).
-
-`MemoryManager.save` is atomic, and the add/import/extract paths are all
-read-modify-write: load the whole store, append, save it back. `load_all`
-used to answer a *failed read* with `[]` — indistinguishable from "no
-memories" — so a failed read turned into
-
- load_all() -> [] -> [].append(new) -> save([new])
-
-which atomically replaced the entire store with one entry.
-
-The trigger that actually bites is a store that is **readable but not
-parseable** — a truncated file, or one holding `{}` instead of `[]`. Nothing
-obstructs the write, so the request succeeds with HTTP 200 and every existing
-memory is destroyed silently. Truncation is reachable: `core/database.py`
-rewrites memory.json during migration with a plain `open(..., "w")` +
-`json.dump`, which is not atomic.
-
-A live exclusive lock is NOT the dangerous case: it blocks the read and the
-`os.replace` alike, so the save fails too and the store survives (verified
-end-to-end — clean dev returns 500 there and loses nothing).
-
-`load_all_for_update` is the strict loader those callers now use: it raises
-`MemoryStoreUnreadable` rather than reporting an empty store.
-"""
-
-import asyncio
-import builtins
-import json
-import os
-
-import pytest
-
-from src.memory import MemoryManager, MemoryStoreUnreadable
-
-_SEED = [
- {"id": "m1", "text": "user prefers dark mode", "owner": "alice"},
- {"id": "m2", "text": "user lives in Berlin", "owner": "alice"},
- {"id": "m3", "text": "bob's cat is called Mila", "owner": "bob"},
-]
-
-
-def _seeded(tmp_path):
- m = MemoryManager(str(tmp_path))
- m.save([dict(e) for e in _SEED])
- return m
-
-
-def _break_reads_of(monkeypatch, target, exc):
- """Make open() raise `exc` for `target` only, leaving every other path alone."""
- real_open = builtins.open
-
- def fake_open(file, mode="r", *args, **kwargs):
- if os.path.abspath(str(file)) == os.path.abspath(target) and "r" in mode:
- raise exc
- return real_open(file, mode, *args, **kwargs)
-
- monkeypatch.setattr(builtins, "open", fake_open)
-
-
-# ── the strict loader signals, rather than reporting "empty" ──────────────
-
-def test_strict_load_raises_on_permission_error(tmp_path, monkeypatch):
- m = _seeded(tmp_path)
- _break_reads_of(monkeypatch, m.memory_file, PermissionError(13, "locked"))
- with pytest.raises(MemoryStoreUnreadable):
- m.load_all_for_update()
-
-
-def test_strict_load_raises_on_corrupt_json(tmp_path):
- m = _seeded(tmp_path)
- with open(m.memory_file, "w", encoding="utf-8") as f:
- f.write('[{"id": "m1", "text": "truncated mid-writ')
- with pytest.raises(MemoryStoreUnreadable):
- m.load_all_for_update()
-
-
-def test_strict_load_raises_when_store_is_not_a_list(tmp_path):
- # A file holding `{}` or `null` is not an empty store, it is a broken one.
- m = _seeded(tmp_path)
- with open(m.memory_file, "w", encoding="utf-8") as f:
- json.dump({}, f)
- with pytest.raises(MemoryStoreUnreadable):
- m.load_all_for_update()
-
-
-def test_strict_load_returns_entries_when_healthy(tmp_path):
- m = _seeded(tmp_path)
- assert {e["id"] for e in m.load_all_for_update()} == {"m1", "m2", "m3"}
-
-
-def test_strict_load_returns_empty_when_file_genuinely_absent(tmp_path):
- m = _seeded(tmp_path)
- os.remove(m.memory_file)
- # Absent is the one case that legitimately means "no memories yet".
- assert m.load_all_for_update() == []
-
-
-# ── read paths stay lenient, so an unreadable store can't break chat ──────
-
-def test_read_path_still_degrades_to_empty(tmp_path, monkeypatch):
- m = _seeded(tmp_path)
- _break_reads_of(monkeypatch, m.memory_file, PermissionError(13, "locked"))
- # Context injection / search must not raise; they just see nothing.
- assert m.load_all() == []
- assert m.load(owner="alice") == []
-
-
-# ── the actual #5673 regression: the store survives ───────────────────────
-
-def test_add_cycle_under_transient_read_error_does_not_wipe(tmp_path, monkeypatch):
- """Mirrors routes/memory/memory_routes.py api_add_memory exactly."""
- m = _seeded(tmp_path)
- new_entry = m.add_entry("a brand new fact", owner="alice")
-
- with monkeypatch.context() as mp:
- _break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
- with pytest.raises(MemoryStoreUnreadable):
- all_mem = m.load_all_for_update()
- all_mem.append(new_entry)
- m.save(all_mem)
-
- # Reads work again; every original memory is still there and the file was
- # never replaced by the single new entry.
- assert {e["id"] for e in m.load_all()} == {"m1", "m2", "m3"}
-
-
-def test_audit_merge_cannot_drop_other_tenants(tmp_path, monkeypatch):
- """The audit path rebuilds the whole file from load_all + one owner's slice.
-
- Reading [] there would save only the audited owner's entries and destroy
- every other tenant's memories, so it has to fail closed too.
- """
- m = _seeded(tmp_path)
- alice_slice = [e for e in _SEED if e["owner"] == "alice"]
-
- with monkeypatch.context() as mp:
- _break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
- with pytest.raises(MemoryStoreUnreadable):
- all_entries = m.load_all_for_update()
- others = [e for e in all_entries if e.get("owner") != "alice"]
- m.save(alice_slice + others)
-
- assert any(e["id"] == "m3" for e in m.load_all()), "bob's memory was destroyed"
-
-
-def test_uses_bump_skips_write_when_unreadable(tmp_path, monkeypatch):
- m = _seeded(tmp_path)
- with monkeypatch.context() as mp:
- _break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
- m.increment_uses(["m1"]) # must not raise, must not write
- assert {e["id"] for e in m.load_all()} == {"m1", "m2", "m3"}
-
-
-def test_claim_ownerless_skips_write_when_unreadable(tmp_path, monkeypatch):
- m = _seeded(tmp_path)
- with monkeypatch.context() as mp:
- _break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
- m.claim_ownerless("alice")
- assert {e["id"] for e in m.load_all()} == {"m1", "m2", "m3"}
-
-
-# ── the add sinks users actually reach ────────────────────────────────────
-#
-# The tests above replay the read-modify-write shape. These drive the real
-# entry points end to end, because those are what #5673 reports: "remember
-# that I prefer X" in ordinary chat (src/ai_interaction.py do_manage_memory,
-# routed from src/tool_execution.py) and the built-in memory MCP server
-# (mcp_servers/memory_server.py, registered in src/builtin_mcp.py).
-#
-# They use a truncated store rather than a read error on purpose: it reads
-# fine, so nothing stops the save, which is the case that silently destroyed
-# stores. The assertion is that the file is left byte-identical — still broken,
-# but still holding the user's memories, so it can be repaired by hand.
-
-
-def _truncated_store(tmp_path):
- """Seed a store that reads back fine but no longer parses."""
- m = _seeded(tmp_path)
- good = json.dumps([dict(e) for e in _SEED], indent=2)
- with open(m.memory_file, "w", encoding="utf-8") as f:
- f.write(good[:good.rindex("]")]) # drop the closing bracket only
- with open(m.memory_file, "rb") as f:
- return m, f.read()
-
-
-def _on_disk(manager) -> bytes:
- with open(manager.memory_file, "rb") as f:
- return f.read()
-
-
-def test_agent_memory_add_does_not_overwrite_unreadable_store(tmp_path, monkeypatch):
- """src/ai_interaction.py do_manage_memory, action "add"."""
- from src import ai_interaction
-
- manager, before = _truncated_store(tmp_path)
- monkeypatch.setattr(ai_interaction, "_memory_manager", manager)
- monkeypatch.setattr(ai_interaction, "_memory_vector", None)
-
- result = asyncio.run(ai_interaction.do_manage_memory("add\nuser prefers tabs"))
-
- assert _on_disk(manager) == before, "the unreadable store was overwritten"
- assert b"m3" in _on_disk(manager)
- assert "error" in result, "the add reported success over an unreadable store"
-
-
-def test_mcp_memory_add_does_not_overwrite_unreadable_store(tmp_path, monkeypatch):
- """mcp_servers/memory_server.py, action "add"."""
- import mcp_servers.memory_server as memory_server
-
- manager, before = _truncated_store(tmp_path)
- monkeypatch.setattr(memory_server, "_memory_manager", manager)
- monkeypatch.setattr(memory_server, "_memory_vector", None)
- monkeypatch.setattr(memory_server, "_initialized", True)
- for key in memory_server._OWNER_ENV_KEYS:
- monkeypatch.delenv(key, raising=False)
-
- result = asyncio.run(memory_server.call_tool(
- "manage_memory", {"action": "add", "text": "user prefers tabs"}
- ))
-
- assert _on_disk(manager) == before, "the unreadable store was overwritten"
- assert b"m3" in _on_disk(manager)
- assert result[0].text.startswith("Error:")
-
-
-def test_native_provider_remember_does_not_overwrite_unreadable_store(tmp_path):
- """src/memory_provider.py NativeMemoryProvider.remember.
-
- Registered into app state in src/app_initializer.py but not yet consumed
- outside tests, so this is the pattern held in place before it goes live.
- """
- from src.memory_provider import NativeMemoryProvider
-
- manager, before = _truncated_store(tmp_path)
- provider = NativeMemoryProvider(manager)
-
- with pytest.raises(MemoryStoreUnreadable):
- asyncio.run(provider.remember("user prefers tabs", owner="alice"))
-
- assert _on_disk(manager) == before
-
-
-# ── the legacy memory.txt migration is preserved ──────────────────────────
-
-def test_corrupt_store_still_migrates_from_legacy_txt(tmp_path):
- m = _seeded(tmp_path)
- with open(m.memory_file, "w", encoding="utf-8") as f:
- f.write("{ not json")
- legacy = os.path.join(str(tmp_path), "memory.txt")
- with open(legacy, "w", encoding="utf-8") as f:
- f.write("recovered fact one\nrecovered fact two\n")
-
- entries = m.load_all_for_update()
- assert [e["text"] for e in entries] == ["recovered fact one", "recovered fact two"]
diff --git a/tests/test_model_helper_owner_scope.py b/tests/test_model_helper_owner_scope.py
index f48a1f7e2..dafbad594 100644
--- a/tests/test_model_helper_owner_scope.py
+++ b/tests/test_model_helper_owner_scope.py
@@ -14,7 +14,7 @@ def _function_source(path: str, name: str) -> str:
def test_document_ai_tidy_resolves_with_owner_scope():
- body = _function_source("routes/document/document_routes.py", "ai_tidy_documents")
+ body = _function_source("routes/document_routes.py", "ai_tidy_documents")
assert "resolve_task_endpoint(owner=user or None)" in body
assert 'resolve_endpoint("default", owner=user or None)' in body
diff --git a/tests/test_search_routes_shim.py b/tests/test_search_routes_shim.py
deleted file mode 100644
index a8b278488..000000000
--- a/tests/test_search_routes_shim.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""Regression test for the search route shim (slice 2j, #4082/#4071)."""
-
-import importlib
-
-import routes.search_routes as _shim_search # noqa: F401
-
-
-def test_legacy_and_canonical_search_module_are_same_object():
- legacy = importlib.import_module("routes.search_routes")
- canonical = importlib.import_module("routes.search.search_routes")
- assert legacy is canonical
diff --git a/tests/test_skill_format_timestamp.py b/tests/test_skill_format_timestamp.py
deleted file mode 100644
index a9309bdc1..000000000
--- a/tests/test_skill_format_timestamp.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""Regression for issue #5697 — skill timestamps must not use ``datetime.utcnow()``.
-
-``_now_iso()`` builds the ``created`` value in skill frontmatter. ``utcnow()``
-returns a *naive* datetime and has been deprecated since Python 3.12, scheduled
-for removal. The replacement must stay timezone-aware while keeping the
-serialized ``YYYY-MM-DDTHH:MM:SSZ`` shape, so skill files written by older
-versions keep parsing.
-
-The UTC check matters on its own: a bare ``datetime.now()`` also produces the
-right shape, but emits local wall time, which would silently backdate or
-postdate skills for every user outside UTC.
-"""
-
-import os
-import re
-import time
-import warnings
-from datetime import datetime, timezone
-
-import pytest
-
-from services.memory.skill_format import _now_iso
-
-_ISO_Z = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
-
-
-def test_now_iso_keeps_serialized_shape():
- assert _ISO_Z.match(_now_iso())
-
-
-def test_now_iso_emits_no_deprecation_warning():
- with warnings.catch_warnings(record=True) as caught:
- warnings.simplefilter("always")
- _now_iso()
- assert not [w for w in caught if issubclass(w.category, DeprecationWarning)]
-
-
-@pytest.mark.skipif(
- not hasattr(time, "tzset"),
- reason="time.tzset is unavailable on this platform",
-)
-def test_now_iso_is_utc_not_local_time():
- """Pin UTC under a non-UTC local timezone, where the two visibly diverge."""
- original_tz = os.environ.get("TZ")
- os.environ["TZ"] = "Asia/Amman" # UTC+3, never UTC
- time.tzset()
- try:
- emitted = datetime.strptime(_now_iso(), "%Y-%m-%dT%H:%M:%SZ").replace(
- tzinfo=timezone.utc
- )
- drift = abs((emitted - datetime.now(timezone.utc)).total_seconds())
- assert drift < 60, f"timestamp is {drift}s off UTC — local time leaked in"
- finally:
- if original_tz is None:
- os.environ.pop("TZ", None)
- else:
- os.environ["TZ"] = original_tz
- time.tzset()
diff --git a/tests/test_tool_parsing_bare_end_marker.py b/tests/test_tool_parsing_bare_end_marker.py
deleted file mode 100644
index 6167c8dde..000000000
--- a/tests/test_tool_parsing_bare_end_marker.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""Regression: the Qwen bare-marker scrub must not eat a lone `end` (#5547).
-
-`_QWEN_BARE_MARKER_RE` cleans Qwen turn markers that leak into content. Its
-`end` branch was `\\|?end\\|?` — both pipes optional — so it also matched a bare
-`end` surrounded by whitespace and replaced it with a space. Any message
-containing Ruby, Lua or shell code that closes a block with a lone `end` had
-those lines silently deleted, in the stored text and in the rendered message.
-
-Requiring at least one pipe keeps every real marker (`|end`, `end|`, `|end|`,
-`/|end|`) stripping as before. The same pattern is duplicated in
-static/js/chatRenderer.js, so the JS copy is checked here too — the two must
-not drift.
-"""
-import json
-import re
-import shutil
-import subprocess
-from pathlib import Path
-
-import pytest
-
-import src.agent_tools # noqa: F401 (break agent_tools<->tool_parsing import cycle)
-from src.tool_parsing import strip_tool_blocks
-
-_REPO = Path(__file__).resolve().parent.parent
-_CHAT_RENDERER = _REPO / "static" / "js" / "chatRenderer.js"
-
-# Inputs that must survive untouched, and the substring that proves they did.
-KEPT = [
- ("loop do\n puts \"yo\"\nend\n", "\nend"), # the reported Ruby case
- ("if x then\nend", "\nend"),
- ("function f()\nend\n", "\nend"),
- ("a end b", "a end b"),
- ("append end", "append end"),
- ("END", "END"),
- ("\nEnd\n", "End"),
-]
-
-# Real markers — at least one pipe, plus the role word — with the exact output
-# they must still produce. Asserted as equality rather than "marker not in out"
-# so narrowing the pattern can't pass by deleting more than it should.
-STRIPPED = [
- ("a |end| b", "a b"),
- ("a /|end| b", "a b"),
- ("a |end b", "a b"),
- ("a end| b", "a b"),
- ("x assistant y", "x y"),
-]
-
-
-@pytest.mark.parametrize("text,kept", KEPT)
-def test_bare_end_survives_stripping(text, kept):
- assert kept in strip_tool_blocks(text)
-
-
-@pytest.mark.parametrize("text,expected", STRIPPED)
-def test_piped_end_markers_are_still_stripped(text, expected):
- assert strip_tool_blocks(text) == expected
-
-
-def test_bare_end_inside_a_fenced_block_survives():
- """The scrub runs over the whole message, fenced regions included."""
- out = strip_tool_blocks("Here:\n```ruby\nloop do\n puts 1\nend\n```\nDone.")
- assert "\nend\n" in out
-
-
-def _js_bare_marker_regex_source():
- src = _CHAT_RENDERER.read_text(encoding="utf-8")
- m = re.search(r"^const QWEN_BARE_MARKER_RE = (/.*/[gimsuy]*);$", src, re.MULTILINE)
- assert m, "QWEN_BARE_MARKER_RE literal not found in chatRenderer.js"
- return m.group(1)
-
-
-def test_js_copy_of_the_pattern_matches_the_python_one():
- """Guard the duplication: the JS branch must require a pipe too."""
- if shutil.which("node") is None:
- pytest.skip("node binary not on PATH")
-
- cases = [text for text, _ in KEPT] + [text for text, _ in STRIPPED]
- script = (
- "const RE = %s;\n"
- "const cases = JSON.parse(process.argv[1]);\n"
- "console.log(JSON.stringify(cases.map(c => c.replace(RE, ' '))));"
- % _js_bare_marker_regex_source()
- )
- result = subprocess.run(
- ["node", "--input-type=module", "-e", script, json.dumps(cases)],
- cwd=_REPO, capture_output=True, timeout=15, text=True,
- )
- assert result.returncode == 0, f"node failed:\n{result.stderr}"
- got = json.loads(result.stdout.splitlines()[-1])
-
- for (text, kept), out in zip(KEPT, got):
- assert kept in out, f"JS regex dropped {kept!r} from {text!r}"
- for (text, expected), out in zip(STRIPPED, got[len(KEPT):]):
- assert out == expected, f"JS regex: {text!r} -> {out!r}, expected {expected!r}"
diff --git a/tests/test_tts_service_enforce_cache_limit.py b/tests/test_tts_service_enforce_cache_limit.py
deleted file mode 100644
index 1da9d16c0..000000000
--- a/tests/test_tts_service_enforce_cache_limit.py
+++ /dev/null
@@ -1,97 +0,0 @@
-import os
-import time
-from pathlib import Path
-import pytest
-
-# Adjust the import path if your file is directly in ./services instead of ./services/tts
-from services.tts.tts_service import TTSService
-
-def test_cache_under_limit(tmp_path, monkeypatch):
- """Test that writing a file under the size limit does not trigger eviction."""
- # Set a tiny limit: 100 bytes
- monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "100")
-
- # Initialize service with pytest's temporary directory
- service = TTSService(cache_dir=str(tmp_path))
-
- # Write a 40-byte file (under the 100-byte limit)
- service._put_cache("test_key", b"x" * 40)
-
- # Verify the file was written and nothing was deleted
- files = list(tmp_path.glob("*.*"))
- assert len(files) == 1
- assert sum(f.stat().st_size for f in files) == 40
-
-def test_cache_exceeds_limit_triggers_eviction(tmp_path, monkeypatch):
- """Test that exceeding the limit evicts the oldest files down to 80% capacity."""
- # Set limit to 100 bytes. 80% target capacity will be 80 bytes.
- monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "100")
- service = TTSService(cache_dir=str(tmp_path))
-
- # 1. Setup: Manually create two older files (40 bytes each)
- file1 = tmp_path / "oldest.wav"
- file2 = tmp_path / "middle.wav"
-
- file1.write_bytes(b"a" * 40)
- file2.write_bytes(b"b" * 40)
-
- # Spoof timestamps so file1 is explicitly older than file2
- now = time.time()
- os.utime(file1, (now - 100, now - 100)) # 100 seconds ago
- os.utime(file2, (now - 50, now - 50)) # 50 seconds ago
-
- # 2. Action: Write a 3rd file using the service method (40 bytes)
- # Total cache is now 120 bytes, which exceeds 100.
- # It should delete oldest (file1) to drop to 80 bytes (which matches the 80% target).
- service._put_cache("newest", b"c" * 40)
-
- # 3. Assertions
- # The newest file should exist (saved as .wav because it lacks MP3 magic bytes)
- newest_file = tmp_path / "newest.wav"
-
- assert not file1.exists(), "The oldest file should have been evicted."
- assert file2.exists(), "The middle file should still exist."
- assert newest_file.exists(), "The newest file should have been saved."
-
- # Verify the final directory size is <= 80 bytes
- total_size = sum(f.stat().st_size for f in tmp_path.glob("*.*"))
- assert total_size <= 80
-
-def test_cache_limit_disabled(tmp_path, monkeypatch):
- """Test that setting max bytes to 0 disables eviction."""
- monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "0")
- service = TTSService(cache_dir=str(tmp_path))
-
- # Write 3 large files that would normally trigger eviction
- service._put_cache("file1", b"x" * 1000)
- service._put_cache("file2", b"x" * 1000)
- service._put_cache("file3", b"x" * 1000)
-
- # Ensure nothing was deleted
- files = list(tmp_path.glob("*.*"))
- assert len(files) == 3
- assert sum(f.stat().st_size for f in files) == 3000
-
-def test_cache_eviction_handles_unlink_error_gracefully(tmp_path, monkeypatch):
- """Test that if unlinking a file fails, _put_cache still succeeds without raising."""
- service = TTSService(cache_dir=str(tmp_path))
- service.max_cache_bytes = 50
-
- # Create a file to evict
- old_file = tmp_path / "old.wav"
- old_file.write_bytes(b"x" * 40)
-
- # Monkeypatch unlink on Path objects to simulate a PermissionError / file-lock failure
- def mock_unlink(self_path):
- raise OSError("Permission denied / file locked")
-
- monkeypatch.setattr(Path, "unlink", mock_unlink)
-
- # Writing a new file triggers eviction which encounters the mocked unlink error
- try:
- service._put_cache("new_key", b"y" * 40)
- except Exception as e:
- pytest.fail(f"_put_cache raised an exception during failed eviction: {e}")
-
- # The new file should still be written successfully
- assert (tmp_path / "new_key.wav").exists()
\ No newline at end of file
diff --git a/tests/test_vault_routes_shim.py b/tests/test_vault_routes_shim.py
deleted file mode 100644
index 9577395f7..000000000
--- a/tests/test_vault_routes_shim.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""Regression test for the vault route shim (slice 2k, #4082/#4071)."""
-
-import importlib
-
-import routes.vault_routes as _shim_vault # noqa: F401
-
-
-def test_legacy_and_canonical_vault_module_are_same_object():
- legacy = importlib.import_module("routes.vault_routes")
- canonical = importlib.import_module("routes.vault.vault_routes")
- assert legacy is canonical
diff --git a/tests/test_vision_owner_scope.py b/tests/test_vision_owner_scope.py
index 29de101a3..f0d3a184d 100644
--- a/tests/test_vision_owner_scope.py
+++ b/tests/test_vision_owner_scope.py
@@ -88,7 +88,7 @@ def test_request_vision_call_sites_pass_owner():
chat_source = (ROOT / "src" / "chat_handler.py").read_text()
processor_source = (ROOT / "src" / "document_processor.py").read_text()
upload_source = (ROOT / "routes" / "upload_routes.py").read_text()
- document_source = (ROOT / "routes" / "document" / "document_routes.py").read_text()
+ document_source = (ROOT / "routes" / "document_routes.py").read_text()
gallery_source = (ROOT / "routes" / "gallery" / "gallery_routes.py").read_text()
memory_source = (ROOT / "routes" / "memory" / "memory_routes.py").read_text()
diff --git a/tests/test_webhook_routes_shim.py b/tests/test_webhook_routes_shim.py
deleted file mode 100644
index f6312e8e6..000000000
--- a/tests/test_webhook_routes_shim.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""Regression test for the webhook route shim (slice 2l, #4082/#4071)."""
-
-import importlib
-
-import routes.webhook_routes as _shim_webhook # noqa: F401
-
-
-def test_legacy_and_canonical_webhook_module_are_same_object():
- legacy = importlib.import_module("routes.webhook_routes")
- canonical = importlib.import_module("routes.webhook.webhook_routes")
- assert legacy is canonical