@@ -1005,7 +1006,7 @@
var tips = mobile ? phone : desktop;
var el = document.getElementById('welcome-tip');
if (el) {
- el.textContent = 'Pick a model if you want, or just type.';
+ el.textContent = tips[Math.floor(Math.random() * tips.length)];
}
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
if (d.version) window._appVersion = d.version;
@@ -2504,7 +2505,7 @@
-
+
@@ -2522,7 +2523,7 @@
-
+
diff --git a/static/js/chat.js b/static/js/chat.js
index ea2d8c1bb..3c8bbe850 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -349,6 +349,9 @@ 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();
@@ -1403,6 +1406,8 @@ 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);
@@ -1716,7 +1721,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
- const abortCtrl = new AbortController();
+ abortCtrl = new AbortController();
abortCtrl._reason = '';
currentAbort = abortCtrl;
@@ -1897,7 +1902,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
let isThinking = false;
let thinkingStartTime = null;
// Streaming TTS: synthesize sentence-by-sentence during streaming
- const streamingTTS = !!(window.aiTTSManager && window.aiTTSManager.autoPlay && window.aiTTSManager.available);
+ 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)
@@ -4787,7 +4792,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (msgIndex < 0) return;
const bodyEl = userMsgElement.querySelector('.body');
- const currentText = bodyEl ? bodyEl.textContent.trim().replace(/\s*\[\d+ attachment\(s\)\]$/, '') : '';
+ let currentText = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
+ currentText = currentText.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 10709679d..1d6e2e4a9 100644
--- a/static/js/chatRenderer.js
+++ b/static/js/chatRenderer.js
@@ -478,7 +478,10 @@ 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;
-const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\|?end\|?|\/?\|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/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;
// 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 e0b20d6b4..83141bfe9 100644
--- a/static/js/composerArrowUpRecall.js
+++ b/static/js/composerArrowUpRecall.js
@@ -143,9 +143,9 @@ export function wireArrowUpRecall(composer, getUserMessages, options = {}) {
return;
}
- // 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.
+ // 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.
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 6a0d3e294..32b906ddc 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,
+ _sanitizeHtml, _renderEmailSummaryError,
_TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO,
_TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS,
} from './emailLibrary/utils.js';
@@ -7259,12 +7259,11 @@ async function _generateSummary(reader, data, btn) {
if (label) label.textContent = 'Summary';
}
} else {
- content.innerHTML = `
${_esc(result.error || 'Failed to summarize')}`;
- panel.remove();
+ _renderEmailSummaryError(content, result);
}
} catch (e) {
sp.destroy();
- panel.remove();
+ _renderEmailSummaryError(content, null);
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 82a5c86ec..f634c9949 100644
--- a/static/js/emailLibrary/utils.js
+++ b/static/js/emailLibrary/utils.js
@@ -30,6 +30,25 @@ 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/sessions.js b/static/js/sessions.js
index cf59d478c..edf83c8a4 100644
--- a/static/js/sessions.js
+++ b/static/js/sessions.js
@@ -1847,6 +1847,10 @@ 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 {
@@ -2313,6 +2317,7 @@ 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/skills.js b/static/js/skills.js
index 84974d446..b45403570 100644
--- a/static/js/skills.js
+++ b/static/js/skills.js
@@ -83,11 +83,9 @@ 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;
- if (cascade && loaded && !_loadPromise && _playSkillsCascade()) {
- _cascadeNext = false;
- updateCount();
- return;
- }
+ // 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 (_loadPromise) return _loadPromise;
_loadPromise = (async () => {
try {
diff --git a/tests/test_api_chat_security.py b/tests/test_api_chat_security.py
index 7dcec324e..d92a31620 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_routes.py",
+ Path(__file__).resolve().parent.parent / "routes" / "webhook" / "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 2df5936ef..135be78ee 100644
--- a/tests/test_backup_import_cross_user_dedup.py
+++ b/tests/test_backup_import_cross_user_dedup.py
@@ -27,6 +27,9 @@ 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 eadc3bc94..022fcbc02 100644
--- a/tests/test_composer_arrow_up_recall_js.py
+++ b/tests/test_composer_arrow_up_recall_js.py
@@ -306,3 +306,24 @@ 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
new file mode 100644
index 000000000..68d049a62
--- /dev/null
+++ b/tests/test_document_routes_shim.py
@@ -0,0 +1,29 @@
+"""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_summary_error_ui_js.py b/tests/test_email_summary_error_ui_js.py
new file mode 100644
index 000000000..1afc3bec9
--- /dev/null
+++ b/tests/test_email_summary_error_ui_js.py
@@ -0,0 +1,52 @@
+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
new file mode 100644
index 000000000..b0ab7b3be
--- /dev/null
+++ b/tests/test_email_summary_llm.py
@@ -0,0 +1,406 @@
+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 7c5bb1645..636270a56 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_routes.py").read_text()
+ document_routes = Path("routes/document/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 53dc671c5..f23cc40de 100644
--- a/tests/test_integration_api_call_ssrf.py
+++ b/tests/test_integration_api_call_ssrf.py
@@ -9,8 +9,13 @@ 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
@@ -97,3 +102,238 @@ 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 bf1ec7d05..a0ad61b4a 100644
--- a/tests/test_integrations_api_call_truncation.py
+++ b/tests/test_integrations_api_call_truncation.py
@@ -83,9 +83,10 @@ 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; 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")),
+ # 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"]),
):
return await integrations.execute_api_call("test_integ", "GET", "/items")
@@ -101,9 +102,10 @@ 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; 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")),
+ # 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"]),
):
result = await integrations.execute_api_call("test_integ", "GET", path)
return result, mock_client
diff --git a/tests/test_manage_skills_action_required.py b/tests/test_manage_skills_action_required.py
new file mode 100644
index 000000000..4efae8026
--- /dev/null
+++ b/tests/test_manage_skills_action_required.py
@@ -0,0 +1,24 @@
+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_memory_add_submit_regression.py b/tests/test_memory_add_submit_regression.py
new file mode 100644
index 000000000..450d63003
--- /dev/null
+++ b/tests/test_memory_add_submit_regression.py
@@ -0,0 +1,54 @@
+"""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("