@@ -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/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/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_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_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_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("