Merge branch 'dev' into fix/add-directory-event-loop

This commit is contained in:
Joeseph Grey
2026-08-12 10:39:22 -06:00
committed by GitHub
152 changed files with 16461 additions and 4745 deletions
+277
View File
@@ -0,0 +1,277 @@
// Tests for the live-thinking throttle that bounds DOM work during long
// reasoning streams (see static/js/liveThinkingThrottle.js).
//
// The throttle's contract is what the terminal paths in chat.js lean on:
// a burst of deltas becomes ONE commit carrying the latest text; flush()
// lands trailing text synchronously and cannot double-commit; cancel()
// guarantees nothing lands after a stream is finished or backgrounded.
//
// Timers are injected, so this runs with no DOM and no real clock.
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createIncrementalDisplayProjector,
createLiveThinkingThrottle,
createThinkingAnalysisGate,
stripLiveThinkingTags,
} from '../static/js/liveThinkingThrottle.js';
function fakeTimers() {
let nextId = 1;
const callbacks = new Map();
const delays = [];
return {
schedule(callback, delay) {
const id = nextId++;
callbacks.set(id, callback);
delays.push(delay);
return id;
},
cancel(id) {
callbacks.delete(id);
},
run(id) {
const callback = callbacks.get(id);
assert.ok(callback, `missing timer ${id}`);
callbacks.delete(id);
callback();
},
pendingIds() {
return [...callbacks.keys()];
},
delays,
};
}
test('coalesces a burst and commits only the latest text after 100 ms', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update('a');
throttle.update('ab');
throttle.update('abc');
assert.deepEqual(commits, []);
assert.deepEqual(timers.delays, [100], 'a burst must schedule exactly one commit');
const [timer] = timers.pendingIds();
timers.run(timer);
assert.deepEqual(commits, ['abc']);
});
test('commit count stays flat as the stream grows', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
// 500 deltas arriving inside one window is the regression this guards:
// the old code committed once per delta, so work grew with stream length.
let text = '';
for (let i = 0; i < 500; i++) {
text += 'token ';
throttle.update(text);
}
assert.deepEqual(commits, []);
assert.equal(timers.pendingIds().length, 1);
timers.run(timers.pendingIds()[0]);
assert.equal(commits.length, 1);
assert.equal(commits[0], text);
});
test('prepares a 200K cumulative stream only at scheduled commit cadence', () => {
const timers = fakeTimers();
const commits = [];
let prepareCalls = 0;
let scannedCharacters = 0;
const throttle = createLiveThinkingThrottle((value) => commits.push(value), {
...timers,
prepare(value) {
prepareCalls += 1;
scannedCharacters += value.length;
return stripLiveThinkingTags(value);
},
});
const delta = 'reasoning '.repeat(10); // 100 characters
let cumulative = '';
for (let i = 0; i < 2000; i++) {
cumulative += delta;
throttle.update(cumulative);
}
assert.equal(cumulative.length, 200_000);
assert.equal(prepareCalls, 0, 'cumulative extraction must not run per delta');
assert.equal(timers.pendingIds().length, 1);
timers.run(timers.pendingIds()[0]);
assert.equal(prepareCalls, 1);
assert.equal(scannedCharacters, 200_000);
assert.deepEqual(commits, [cumulative]);
});
test('ordinary answers and reasoning deltas do not request cumulative analysis', () => {
const startsReasoning = (text) => /^\s*thinking(?:\s+process)?\s*:/i.test(text);
const ordinaryGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
let ordinary = '';
let ordinaryAnalyses = 0;
for (let i = 0; i < 2000; i++) {
ordinary += i === 0 ? 'Here is the answer. ' : 'answer '.repeat(10);
if (ordinaryGate.shouldAnalyze(ordinary)) ordinaryAnalyses += 1;
}
assert.equal(ordinaryAnalyses, 0);
const thinkingGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
let thinking = 'Thin';
assert.equal(thinkingGate.shouldAnalyze(thinking), false);
thinking += 'king: inspect the problem';
assert.equal(thinkingGate.shouldAnalyze(thinking), true);
for (let i = 0; i < 2000; i++) {
thinking += ' reasoning'.repeat(10);
assert.equal(thinkingGate.shouldAnalyze(thinking, { isThinking: true, nonTagThinking: true }), false);
}
thinking += '\n\nHere is the answer';
assert.equal(thinkingGate.shouldAnalyze(thinking, { isThinking: true, nonTagThinking: true }), true);
const whitespaceGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
let whitespaceThinking = ' '.repeat(250);
assert.equal(whitespaceGate.shouldAnalyze(whitespaceThinking), false);
whitespaceThinking += 'Thinking: bounded probe';
assert.equal(whitespaceGate.shouldAnalyze(whitespaceThinking), true);
});
test('split namespaced closes and false-close deadlines request analysis', () => {
let clock = 100;
const gate = createThinkingAnalysisGate({ now: () => clock });
let text = '<mm:think>x</mm:';
assert.equal(gate.shouldAnalyze(text, { isThinking: true }), true, 'fresh opening tag is analyzed');
text += 'think>answer';
assert.equal(gate.shouldAnalyze(text, { isThinking: true }), true, 'split namespaced close is analyzed');
text += ' still waiting';
assert.equal(gate.shouldAnalyze(text, { isThinking: true, recheckAt: 500 }), false);
clock = 500;
text += ' next delta';
assert.equal(gate.shouldAnalyze(text, { isThinking: true, recheckAt: 500 }), true);
const attributedGate = createThinkingAnalysisGate();
let attributed = `<think data-provider="${'x'.repeat(400)}"`;
assert.equal(attributedGate.shouldAnalyze(attributed), false);
attributed += '>reasoning';
assert.equal(attributedGate.shouldAnalyze(attributed), true, 'bounded carry preserves split tag attributes');
});
test('display projection is append-only and filters a structured tail once', () => {
let filterCalls = 0;
let filteredCharacters = 0;
const projector = createIncrementalDisplayProjector((text) => {
filterCalls += 1;
filteredCharacters += text.length;
return text.replace(/\[TOOL_CALL\][\s\S]*$/i, '');
});
let text = '';
for (let i = 0; i < 2000; i++) {
const delta = i === 0 ? 'Here is the answer. ' : 'ordinary text ';
text += delta;
assert.equal(projector.append(delta, text), text);
}
assert.equal(filterCalls, 0, 'ordinary deltas never run the cumulative filter');
text += '[TOOL_';
projector.append('[TOOL_', text);
text += 'CALL]{"name":"read"}';
const beforeToolPayload = projector.append('CALL]{"name":"read"}', text);
for (let i = 0; i < 2000; i++) {
const delta = 'payload ';
text += delta;
assert.equal(projector.append(delta, text), beforeToolPayload);
}
assert.equal(filterCalls, 1, 'structured payload filtering happens only at its boundary');
assert.ok(filteredCharacters < text.length, 'filter work is bounded by the first structured boundary');
});
test('literal escaped tags survive and malformed live tags retain trailing text', () => {
assert.equal(
stripLiveThinkingTags('&lt;think&gt;literal&lt;/think&gt;'),
'&lt;think&gt;literal&lt;/think&gt;',
);
assert.equal(
stripLiveThinkingTags('<think>first</think> middle <thinking mode="deep">trailing'),
'first middle trailing',
);
assert.equal(stripLiveThinkingTags('answer with 2 < 3 and 5 > 4'), 'answer with 2 < 3 and 5 > 4');
});
test('terminal flush prepares and commits the complete trailing cumulative text', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), {
...timers,
prepare: stripLiveThinkingTags,
});
throttle.update('<think>reasoning without a closing tag');
assert.equal(throttle.flush(), true);
assert.deepEqual(commits, ['reasoning without a closing tag']);
assert.deepEqual(timers.pendingIds(), []);
});
test('independent throttles cannot commit cancelled text into another session', () => {
const timers = fakeTimers();
const commits = [];
const first = createLiveThinkingThrottle((value) => commits.push(['first', value]), timers);
const second = createLiveThinkingThrottle((value) => commits.push(['second', value]), timers);
first.update('stale first-session text');
second.update('current second-session text');
first.cancel();
assert.equal(second.flush(), true);
assert.deepEqual(timers.pendingIds(), []);
assert.deepEqual(commits, [['second', 'current second-session text']]);
});
test('flush synchronously preserves trailing text and cancels the pending callback', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update('trailing text');
assert.equal(throttle.flush(), true);
assert.deepEqual(commits, ['trailing text']);
assert.deepEqual(timers.pendingIds(), []);
assert.equal(throttle.flush(), false, 'clean flush must not duplicate the commit');
});
test('cancel discards pending work without a late DOM commit', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update('stale session text');
throttle.cancel();
assert.deepEqual(timers.pendingIds(), []);
assert.deepEqual(commits, []);
});
test('a cancelled throttle accepts new work again', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update('discarded');
throttle.cancel();
throttle.update('fresh');
assert.equal(throttle.flush(), true);
assert.deepEqual(commits, ['fresh']);
});
test('coerces nullish updates instead of committing undefined', () => {
const timers = fakeTimers();
const commits = [];
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
throttle.update(null);
throttle.flush();
assert.deepEqual(commits, ['']);
});
+128
View File
@@ -0,0 +1,128 @@
"""Windows execution contract for the agent Bash tool."""
import pytest
from src.agent_tools import subprocess_tools
@pytest.mark.asyncio
async def test_windows_bash_uses_git_bash_with_structural_cwd(monkeypatch):
captured = {}
bash = r"C:\Program Files\Git\bin\bash.exe"
workspace = r"D:\Workspaces\Project with spaces"
process = object()
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: bash)
async def fake_exec(*argv, **kwargs):
captured["argv"] = argv
captured["kwargs"] = kwargs
return process
async def fail_shell(*_args, **_kwargs):
pytest.fail("native Windows Bash must not execute through cmd.exe")
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fake_exec)
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", fail_shell)
result = await subprocess_tools._create_bash_subprocess(
"pwd; cat package.json",
cwd=workspace,
env={"HOME": r"C:\Odysseus\data"},
)
assert result is process
assert captured["argv"] == (bash, "-c", "pwd; cat package.json")
assert captured["kwargs"]["cwd"] == workspace
@pytest.mark.asyncio
async def test_windows_bash_without_git_bash_fails_clearly(monkeypatch):
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: None)
async def fail_spawn(*_args, **_kwargs):
pytest.fail("no subprocess should start without Git Bash")
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fail_spawn)
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", fail_spawn)
with pytest.raises(RuntimeError, match="Git Bash is required"):
await subprocess_tools._create_bash_subprocess("pwd", cwd=r"C:\Work")
@pytest.mark.asyncio
async def test_bash_tool_returns_install_hint_when_git_bash_is_missing(monkeypatch):
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: None)
result = await subprocess_tools.BashTool().execute(
"pwd",
{"subproc_env": {}, "session_id": None},
)
assert result["exit_code"] == 1
assert "install Git for Windows" in result["error"]
@pytest.mark.asyncio
async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch):
captured = {}
workspace = r"D:\Workspaces\Project with spaces"
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(
subprocess_tools.shutil,
"which",
lambda name: r"C:\msys64\usr\bin\tmux.exe",
)
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: workspace)
async def fail_tmux(*_args, **_kwargs):
pytest.fail("native Windows must not enter the POSIX tmux path")
async def fake_create(command, **kwargs):
captured["command"] = command
captured["kwargs"] = kwargs
return object()
async def fake_stream(_process, **_kwargs):
return "ok", "", 0, False
monkeypatch.setattr(subprocess_tools, "_run_tmux_bash", fail_tmux)
monkeypatch.setattr(subprocess_tools, "_create_bash_subprocess", fake_create)
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
result = await subprocess_tools.BashTool().execute(
"pwd",
{"subproc_env": {}, "session_id": "chat-1"},
)
assert result == {"output": "ok", "exit_code": 0}
assert captured["command"] == "pwd"
assert captured["kwargs"]["cwd"] == workspace
@pytest.mark.asyncio
async def test_posix_bash_keeps_existing_shell_path(monkeypatch):
captured = {}
process = object()
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", False)
async def fake_shell(command, **kwargs):
captured["command"] = command
captured["kwargs"] = kwargs
return process
async def fail_exec(*_args, **_kwargs):
pytest.fail("POSIX behavior must continue through create_subprocess_shell")
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", fake_shell)
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fail_exec)
result = await subprocess_tools._create_bash_subprocess("pwd", cwd="/tmp/work")
assert result is process
assert captured == {"command": "pwd", "kwargs": {"cwd": "/tmp/work"}}
+1 -1
View File
@@ -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)
+44 -6
View File
@@ -1,17 +1,19 @@
"""Tests for ``core.atomic_io`` durability and crash-safety behavior.
``core.atomic_io`` provides ``atomic_write_json`` and ``atomic_write_text``.
Both write to a sibling ``.tmp.<pid>`` file, ``fsync`` it, then ``os.replace``
into place so a crash mid-write leaves the previous good copy untouched rather
than a truncated/empty file.
Both write to a sibling ``.tmp.<random>`` file, ``fsync`` it, then
``os.replace`` into place so a crash mid-write leaves the previous good copy
untouched rather than a truncated/empty file.
These tests cover the happy path (round-trip, indent, parent-dir creation,
full overwrite, no leftover tmp) and the two failure paths the implementation
guarantees: the target file is preserved when serialization fails before the
replace, and when ``os.replace`` itself fails.
full overwrite, no leftover tmp), the two failure paths the implementation
guarantees (the target file is preserved when serialization fails before the
replace, and when ``os.replace`` itself fails), and that two concurrent
writers to the same path don't collide on the same temp file.
"""
import importlib.util
import json
import threading
from pathlib import Path
import pytest
@@ -84,6 +86,42 @@ def test_atomic_write_json_leaves_no_tmp_file(tmp_path):
assert _tmp_siblings(tmp_path, "data.json") == []
def test_atomic_write_json_concurrent_writers_do_not_collide(tmp_path):
# Both writers run in this same process, so a PID-based tmp suffix is
# identical for both: whichever writer finishes first unlinks the tmp
# file (via os.replace) out from under the other, which then raises
# FileNotFoundError on its own os.replace instead of landing its write.
target = tmp_path / "settings.json"
orig_dump = json.dump
barrier = threading.Barrier(2)
errors = []
def slow_dump(obj, fp, **kwargs):
orig_dump(obj, fp, **kwargs)
fp.flush()
barrier.wait()
def write(payload):
try:
atomic_write_json(str(target), payload)
except Exception as exc: # noqa: BLE001 - captured for the assertion below
errors.append(exc)
json.dump = slow_dump
try:
t1 = threading.Thread(target=write, args=({"writer": "A"},))
t2 = threading.Thread(target=write, args=({"writer": "B"},))
t1.start()
t2.start()
t1.join()
t2.join()
finally:
json.dump = orig_dump
assert errors == []
assert json.loads(target.read_text(encoding="utf-8"))["writer"] in ("A", "B")
# ---------------------------------------------------------------------------
# atomic_write_json — failure path: target preserved on serialization error.
# ---------------------------------------------------------------------------
@@ -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)
+538
View File
@@ -0,0 +1,538 @@
"""Default calendar creation belongs to the caller's transaction.
Before this regression, ``_ensure_default_calendar`` committed independently.
If event persistence then failed, the event rolled back but a new ``Personal``
calendar remained (``calendar_count=1``, ``event_count=0``).
"""
import json
import threading
from contextlib import contextmanager
from datetime import datetime, timedelta
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, event
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import NullPool
from tests.helpers.import_state import clear_fake_database_modules
clear_fake_database_modules()
import core.database as cdb # noqa: E402
import routes.calendar_routes as calendar_routes # noqa: E402
from core.database import CalendarCal, CalendarEvent # noqa: E402
from routes.calendar_routes import EventCreate # noqa: E402
from routes.calendar_routes import ( # noqa: E402
_default_calendar_id,
_ensure_default_calendar,
)
class _RejectEventCommit(Session):
"""Reproduce an event commit failure after default-calendar creation."""
def commit(self):
if any(isinstance(row, CalendarEvent) for row in self.new):
raise RuntimeError("commit guard rejected event commit")
return super().commit()
@pytest.fixture
def session_factory(tmp_path, monkeypatch):
engine = create_engine(
f"sqlite:///{tmp_path / 'calendar.db'}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(engine)
factory = sessionmaker(
bind=engine,
autoflush=False,
autocommit=False,
class_=_RejectEventCommit,
)
monkeypatch.setattr(cdb, "SessionLocal", factory)
monkeypatch.setattr(calendar_routes, "SessionLocal", factory)
try:
yield factory
finally:
engine.dispose()
def _request():
return SimpleNamespace(state=SimpleNamespace(current_user="alice"))
def _endpoint(method, suffix):
router = calendar_routes.setup_calendar_routes()
for route in router.routes:
if route.path.endswith(suffix) and method in route.methods:
return route.endpoint
raise RuntimeError(f"{method} *{suffix} not found")
def _counts(factory):
db = factory()
try:
return db.query(CalendarCal).count(), db.query(CalendarEvent).count()
finally:
db.close()
async def test_route_event_failure_rolls_back_new_default_calendar(session_factory):
create_event = _endpoint("POST", "/events")
with pytest.raises(HTTPException) as caught:
await create_event(
_request(),
EventCreate(summary="Planning", dtstart="2126-07-20T09:00:00Z"),
)
assert caught.value.status_code == 500
assert _counts(session_factory) == (0, 0)
async def test_route_event_validation_failure_rolls_back_new_default_calendar(
session_factory,
):
create_event = _endpoint("POST", "/events")
with pytest.raises(HTTPException) as caught:
await create_event(
_request(),
EventCreate(summary="Planning", dtstart="not-a-datetime"),
)
assert caught.value.status_code == 500
assert _counts(session_factory) == (0, 0)
async def test_tool_event_failure_rolls_back_new_default_calendar(session_factory):
from src.tools.calendar import do_manage_calendar
result = await do_manage_calendar(
json.dumps({
"action": "create_event",
"summary": "Planning",
"dtstart": "2126-07-20T09:00:00Z",
}),
owner="alice",
)
assert result["exit_code"] == 1
assert "commit guard rejected event commit" in result["error"]
assert _counts(session_factory) == (0, 0)
async def test_tool_event_validation_failure_rolls_back_new_default_calendar(
session_factory,
):
from src.tools.calendar import do_manage_calendar
result = await do_manage_calendar(
json.dumps({
"action": "create_event",
"summary": "Planning",
"dtstart": "not-a-datetime",
}),
owner="alice",
)
assert result["exit_code"] == 1
assert "Could not parse dtstart" in result["error"]
assert _counts(session_factory) == (0, 0)
async def test_route_list_calendars_persists_lazy_default(session_factory):
list_calendars = _endpoint("GET", "/calendars")
result = await list_calendars(_request())
assert [calendar["name"] for calendar in result["calendars"]] == ["Personal"]
assert _counts(session_factory) == (1, 0)
async def test_tool_list_calendars_persists_lazy_default(session_factory):
from src.tools.calendar import do_manage_calendar
result = await do_manage_calendar(
json.dumps({"action": "list_calendars"}),
owner="alice",
)
assert result["exit_code"] == 0
assert [calendar["name"] for calendar in result["calendars"]] == ["Personal"]
assert _counts(session_factory) == (1, 0)
def test_repeated_rename_and_reuse_uses_stable_fallback_ids(tmp_path):
engine = create_engine(
f"sqlite:///{tmp_path / 'renamed-calendar.db'}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
db = factory()
try:
first = _ensure_default_calendar(db, "alice")
assert first.id == _default_calendar_id("alice")
db.commit()
# The supported user-rename migration changes owner columns while
# deliberately preserving durable row identifiers.
first.owner = "bob"
db.commit()
second = _ensure_default_calendar(db, "alice")
assert second.id == _default_calendar_id("alice", 1)
db.commit()
# Repeating the same lifecycle must advance deterministically instead
# of failing or choosing a random identifier.
second.owner = "carol"
db.commit()
third = _ensure_default_calendar(db, "alice")
assert third.id == _default_calendar_id("alice", 2)
db.commit()
rows = db.query(CalendarCal).order_by(CalendarCal.owner).all()
assert [(row.owner, row.id) for row in rows] == [
("alice", _default_calendar_id("alice", 2)),
("bob", _default_calendar_id("alice")),
("carol", _default_calendar_id("alice", 1)),
]
finally:
db.close()
engine.dispose()
def _assert_concurrent_first_use(tmp_path, occupied_owner=None):
engine = create_engine(
f"sqlite:///{tmp_path / 'concurrent-calendar.db'}",
connect_args={"check_same_thread": False, "timeout": 10},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
expected_collision_index = 0
if occupied_owner is not None:
seed = factory()
try:
seed.add(CalendarCal(
id=_default_calendar_id("alice"),
owner=occupied_owner,
name="Personal",
source="local",
))
seed.commit()
expected_collision_index = 1
finally:
seed.close()
first_staged = threading.Event()
second_selected = threading.Event()
errors = []
@event.listens_for(engine, "after_cursor_execute")
def observe_second_gap(conn, cursor, statement, parameters, context, executemany):
if (
threading.current_thread().name == "calendar-worker-second"
and statement.lstrip().upper().startswith("SELECT")
and "FROM calendars" in statement
):
second_selected.set()
def create_default(worker, hold=False):
db = factory()
try:
if not hold:
assert first_staged.wait(5)
cal = _ensure_default_calendar(db, "alice")
start = datetime(2126, 7, 20, 9 if hold else 10)
db.add(CalendarEvent(
uid=worker,
calendar_id=cal.id,
summary=f"Event {worker}",
dtstart=start,
dtend=start + timedelta(hours=1),
))
if hold:
first_staged.set()
# The second session has observed the uncommitted gap before
# this transaction releases its writer reservation.
assert second_selected.wait(5)
db.commit()
assert cal.id == _default_calendar_id("alice", expected_collision_index)
except BaseException as exc: # pragma: no cover - asserted below
errors.append((worker, exc))
db.rollback()
finally:
db.close()
first = threading.Thread(
target=create_default,
args=("first", True),
name="calendar-worker-first",
)
second = threading.Thread(
target=create_default,
args=("second",),
name="calendar-worker-second",
)
first.start()
second.start()
first.join(10)
second.join(10)
try:
assert not first.is_alive() and not second.is_alive()
assert errors == []
db = factory()
try:
rows = db.query(CalendarCal).filter(CalendarCal.owner == "alice").all()
assert [(row.id, row.name) for row in rows] == [
(_default_calendar_id("alice", expected_collision_index), "Personal")
]
assert db.query(CalendarEvent).count() == 2
if occupied_owner is not None:
occupied = db.query(CalendarCal).filter(
CalendarCal.id == _default_calendar_id("alice"),
).one()
assert occupied.owner == occupied_owner
finally:
db.close()
finally:
engine.dispose()
def test_concurrent_first_use_creates_one_sqlite_default(tmp_path):
_assert_concurrent_first_use(tmp_path)
def test_concurrent_first_use_after_rename_creates_one_fallback_default(tmp_path):
_assert_concurrent_first_use(tmp_path, occupied_owner="bob")
def test_sqlite_default_stays_in_callers_transaction(session_factory):
db = session_factory()
try:
cal = _ensure_default_calendar(db, "rollback-owner")
assert cal.id == _default_calendar_id("rollback-owner")
db.rollback()
finally:
db.close()
verify = session_factory()
try:
assert (
verify.query(CalendarCal)
.filter(CalendarCal.owner == "rollback-owner")
.count()
== 0
)
finally:
verify.close()
def test_sqlite_fallback_default_stays_in_callers_transaction(session_factory):
seed = session_factory()
try:
seed.add(CalendarCal(
id=_default_calendar_id("alice"),
owner="bob",
name="Personal",
source="local",
))
seed.commit()
finally:
seed.close()
db = session_factory()
try:
cal = _ensure_default_calendar(db, "alice")
assert cal.id == _default_calendar_id("alice", 1)
db.rollback()
finally:
db.close()
verify = session_factory()
try:
assert verify.query(CalendarCal).filter(CalendarCal.owner == "alice").count() == 0
assert verify.query(CalendarCal).filter(CalendarCal.owner == "bob").count() == 1
finally:
verify.close()
class _FakeDialect:
name = "postgresql"
class _FakeBind:
dialect = _FakeDialect()
class _FakeQuery:
def __init__(self, session):
self.session = session
def filter(self, *conditions):
return self
def with_for_update(self):
self.session.locking_read = True
return self
def first(self):
self.session.query_count += 1
if self.session.query_count == 1:
return None
return self.session.winner
class _GenericRaceSession:
"""Minimal non-SQLite session that loses the deterministic-ID race."""
def __init__(self):
self.query_count = 0
self.nested_entries = 0
self.locking_read = False
self.candidate = None
self.winner = CalendarCal(
id=_default_calendar_id("alice"),
owner="alice",
name="Personal",
source="local",
)
def get_bind(self):
return _FakeBind()
def query(self, model):
assert model is CalendarCal
return _FakeQuery(self)
@contextmanager
def begin_nested(self):
self.nested_entries += 1
yield
def add(self, row):
self.candidate = row
def flush(self):
raise IntegrityError("insert", {}, RuntimeError("duplicate primary key"))
def test_generic_backend_lost_race_recovers_inside_savepoint():
db = _GenericRaceSession()
winner = _ensure_default_calendar(db, "alice")
assert winner is db.winner
assert db.nested_entries == 1
assert db.locking_read is True
assert db.candidate.id == db.winner.id
def test_generic_backend_unattributed_integrity_error_is_not_retried():
db = _GenericRaceSession()
db.winner = None
with pytest.raises(IntegrityError):
_ensure_default_calendar(db, "alice")
assert db.nested_entries == 1
class _GenericRenamedSlotSession(_GenericRaceSession):
"""A different owner occupies slot zero; slot one remains available."""
def __init__(self):
super().__init__()
self.candidates = []
self.winner = CalendarCal(
id=_default_calendar_id("alice"),
owner="bob",
name="Personal",
source="local",
)
def add(self, row):
self.candidate = row
self.candidates.append(row)
def flush(self):
if len(self.candidates) == 1:
raise IntegrityError("insert", {}, RuntimeError("duplicate primary key"))
def test_generic_backend_renamed_slot_advances_inside_savepoint():
db = _GenericRenamedSlotSession()
fallback = _ensure_default_calendar(db, "alice")
assert fallback is db.candidates[-1]
assert fallback.id == _default_calendar_id("alice", 1)
assert fallback.owner == "alice"
assert db.nested_entries == 2
assert db.locking_read is True
assert db.winner.owner == "bob"
def test_generic_backend_fallback_keeps_outer_transaction_usable(tmp_path):
engine = create_engine(
f"sqlite:///{tmp_path / 'generic-savepoint-calendar.db'}",
poolclass=NullPool,
)
cdb.Base.metadata.create_all(engine)
# SQLite supplies a lightweight local SQL executor here; changing only the
# dispatch name exercises the real Session/savepoint branch used by
# PostgreSQL-style backends without pretending to validate their dialect.
engine.dialect.name = "postgresql"
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
seed = factory()
try:
seed.add(CalendarCal(
id=_default_calendar_id("alice"),
owner="bob",
name="Personal",
source="local",
))
seed.commit()
finally:
seed.close()
db = factory()
try:
cal = _ensure_default_calendar(db, "alice")
start = datetime(2126, 7, 20, 9)
db.add(CalendarEvent(
uid="after-fallback",
calendar_id=cal.id,
summary="Atomic",
dtstart=start,
dtend=start + timedelta(hours=1),
))
db.commit()
finally:
db.close()
verify = factory()
try:
assert [
(row.owner, row.id)
for row in verify.query(CalendarCal).order_by(CalendarCal.owner).all()
] == [
("alice", _default_calendar_id("alice", 1)),
("bob", _default_calendar_id("alice")),
]
assert verify.query(CalendarEvent).count() == 1
finally:
verify.close()
engine.dispose()
+2 -1
View File
@@ -1,3 +1,4 @@
import re
from pathlib import Path
@@ -13,7 +14,7 @@ def test_stream_render_helpers_are_visible_to_catch_block():
assert "let _cancelThinkingTimer = () => {};" in outer_scope
assert "let _removeThinkingSpinner = () => {};" in outer_scope
assert "_renderStream = () => {" in try_body
assert re.search(r"(?m)^\s*_renderStream\s*=", try_body)
assert "_cancelThinkingTimer = () => {" in try_body
assert "_removeThinkingSpinner = () => {" in try_body
assert "function _renderStream()" not in try_body
+35
View File
@@ -0,0 +1,35 @@
"""Regression coverage for authoritative Python CI validation."""
import re
from pathlib import Path
_WORKFLOW = (
Path(__file__).resolve().parent.parent / ".github" / "workflows" / "ci.yml"
)
def _indented_block(text: str, heading: str, indent: int) -> str:
pattern = re.compile(
rf"(?ms)^{' ' * indent}{re.escape(heading)}:\n"
rf"(?P<body>(?:(?:{' ' * (indent + 2)}.*|\s*)\n)*)"
)
match = pattern.search(text)
assert match is not None, f"missing {heading!r} block"
return match.group(0)
def test_ci_runs_on_integrated_dev_pushes():
workflow = _WORKFLOW.read_text()
push = _indented_block(workflow, "push", 2)
assert re.search(r"(?m)^ branches:\s*\[main,\s*dev\]\s*$", push)
assert "paths-ignore:" not in push
def test_python_tests_are_authoritative():
workflow = _WORKFLOW.read_text()
python_tests = _indented_block(workflow, "python-tests", 2)
assert "python -m pytest -q" in python_tests
assert "continue-on-error:" not in python_tests
+21
View File
@@ -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"
)
+6 -1
View File
@@ -723,7 +723,12 @@ def test_local_windows_download_pid_tracks_inner_bash_and_stop_kills_tree():
routes_src = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
running_src = (Path(__file__).resolve().parents[1] / "static" / "js" / "cookbookRunning.js").read_text(encoding="utf-8")
assert 'printf \'%s\\\\n\' \\"$$\\" > {pp}' in routes_src
# The Windows-local runner publishes Python's valid Win32 fallback before
# allowing Git Bash to replace it with /proc/$$/winpid.
assert "_windows_local_pid_record_line(pid_path, pid_ready_path)" in routes_src
assert "/proc/$$/winpid" in routes_src
assert "pid_ready_path.touch()" in routes_src
assert '\\"$$\\" > {pp}' not in routes_src
assert "function Stop-Tree([int]$Id)" in running_src
assert "('ParentProcessId = ' + $Id)" in running_src
assert "Stop-Tree ([int]$p)" in running_src
@@ -0,0 +1,180 @@
"""Behavioral regression coverage for Windows-local Cookbook PID recording."""
import os
import subprocess
import time
from pathlib import Path
from routes.cookbook_routes import _windows_local_pid_record_line
ROOT = Path(__file__).resolve().parents[1]
COOKBOOK_ROUTES = ROOT / "routes" / "cookbook_routes.py"
def _fake_cat(tmp_path: Path, body: str) -> Path:
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
cat = fake_bin / "cat"
cat.write_text("#!/bin/sh\n" + body + "\n", encoding="utf-8")
cat.chmod(0o755)
return fake_bin
def _env_for(fake_bin: Path, **extra: str) -> dict[str, str]:
env = dict(os.environ)
env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "")
env.update(extra)
return env
def _run_pid_line(
pid_path: Path,
ready_path: Path,
fake_bin: Path,
**extra_env: str,
) -> subprocess.CompletedProcess:
return subprocess.run(
["bash", "-c", _windows_local_pid_record_line(pid_path, ready_path)],
capture_output=True,
text=True,
env=_env_for(fake_bin, **extra_env),
timeout=10,
)
def test_windows_local_pid_line_records_numeric_winpid_after_fallback(tmp_path):
pid_path = tmp_path / "serve.pid"
ready_path = tmp_path / "serve.pid.ready"
pid_path.write_text("11111", encoding="utf-8")
ready_path.touch()
cat_arg = tmp_path / "cat-arg.txt"
fake_bin = _fake_cat(
tmp_path,
'printf "%s\\n" "$1" > "$FAKE_CAT_ARG"\n'
'printf "%s\\n" "$FAKE_WINPID"',
)
result = _run_pid_line(
pid_path,
ready_path,
fake_bin,
FAKE_CAT_ARG=str(cat_arg),
FAKE_WINPID="42324",
)
assert result.returncode == 0, result.stderr
assert pid_path.read_text(encoding="utf-8").strip() == "42324"
assert not ready_path.exists()
proc_path = cat_arg.read_text(encoding="utf-8").strip()
parts = proc_path.strip("/").split("/")
assert len(parts) == 3
assert parts[0] == "proc"
assert parts[1].isdigit()
assert parts[2] == "winpid"
def test_windows_local_pid_line_waits_for_python_fallback_before_replacing(tmp_path):
pid_path = tmp_path / "serve.pid"
ready_path = tmp_path / "serve.pid.ready"
fake_bin = _fake_cat(
tmp_path,
'printf "%s\\n" "$FAKE_WINPID"',
)
proc = subprocess.Popen(
[
"bash",
"-c",
_windows_local_pid_record_line(pid_path, ready_path),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=_env_for(fake_bin, FAKE_WINPID="42324"),
)
# The inner shell has started, but Python has not published its fallback yet.
time.sleep(0.05)
assert proc.poll() is None
assert not pid_path.exists()
# Simulate the post-Popen Python publication order.
pid_path.write_text("31100", encoding="utf-8")
ready_path.touch()
stdout, stderr = proc.communicate(timeout=10)
assert proc.returncode == 0, stderr or stdout
assert pid_path.read_text(encoding="utf-8").strip() == "42324"
assert not ready_path.exists()
def test_windows_local_pid_line_preserves_outer_pid_when_mapping_missing(tmp_path):
pid_path = tmp_path / "serve.pid"
ready_path = tmp_path / "serve.pid.ready"
pid_path.write_text("31100", encoding="utf-8")
ready_path.touch()
fake_bin = _fake_cat(tmp_path, "exit 1")
result = _run_pid_line(
pid_path,
ready_path,
fake_bin,
)
assert result.returncode == 0, result.stderr
assert pid_path.read_text(encoding="utf-8").strip() == "31100"
assert not ready_path.exists()
def test_windows_local_pid_line_rejects_malformed_mapping(tmp_path):
pid_path = tmp_path / "serve.pid"
ready_path = tmp_path / "serve.pid.ready"
pid_path.write_text("31100", encoding="utf-8")
ready_path.touch()
fake_bin = _fake_cat(
tmp_path,
'printf "not-a-win32-pid\\n"',
)
result = _run_pid_line(
pid_path,
ready_path,
fake_bin,
)
assert result.returncode == 0, result.stderr
assert pid_path.read_text(encoding="utf-8").strip() == "31100"
assert not ready_path.exists()
def test_local_windows_launcher_publishes_fallback_before_releasing_inner_runner():
source = COOKBOOK_ROUTES.read_text(encoding="utf-8")
start = source.index(" def _launch_local_detached(")
end = source.index(
' @router.post("/api/model/download")',
start,
)
launcher = source[start:end]
assert "_windows_local_pid_record_line(pid_path, pid_ready_path)" in launcher
assert "pid_ready_path.unlink(missing_ok=True)" in launcher
fallback = launcher.index(
'pid_path.write_text(str(proc.pid), encoding="utf-8")'
)
release = launcher.index("pid_ready_path.touch()")
assert fallback < release
# Never write Git Bash's bare MSYS $$ to the session pid file.
assert '\\"$$\\" > {pp}' not in launcher
+29
View File
@@ -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
@@ -0,0 +1,522 @@
"""Regressions for process-safe email-account default mutations.
The file-backed SQLite fixture uses a fresh connection for every Session.
That exercises the same database lock boundary used by separate web workers,
rather than relying on an in-process Python lock.
"""
import asyncio
import json
import sys
import threading
import types
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, create_mock_engine, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
@pytest.fixture
def account_db(tmp_path, monkeypatch):
from core import database as core_db
engine = create_engine(
f"sqlite:///{tmp_path / 'accounts.db'}",
connect_args={"check_same_thread": False, "timeout": 5},
poolclass=NullPool,
)
core_db.Base.metadata.create_all(engine)
factory = sessionmaker(
bind=engine,
autocommit=False,
autoflush=False,
)
monkeypatch.setattr(core_db, "SessionLocal", factory)
yield factory
engine.dispose()
def _endpoint(method, path):
from routes import email_routes
with mock.patch.object(email_routes, "_start_poller"):
router = email_routes.setup_email_routes()
for route in router.routes:
if route.path == path and method in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError(f"email route not found: {method} {path}")
def _named_endpoint(router, name):
for route in router.routes:
if getattr(getattr(route, "endpoint", None), "__name__", "") == name:
return route.endpoint
raise AssertionError(f"route not found: {name}")
def _seed_account(factory, account_id, owner, *, is_default=False, enabled=True):
from core.database import EmailAccount
db = factory()
try:
db.add(
EmailAccount(
id=account_id,
owner=owner,
name=account_id,
is_default=is_default,
enabled=enabled,
)
)
db.commit()
finally:
db.close()
def _rows(factory):
from core.database import EmailAccount
db = factory()
try:
return [
(row.id, row.owner, bool(row.is_default))
for row in db.query(EmailAccount).order_by(EmailAccount.id).all()
]
finally:
db.close()
def _install_lock_pause(monkeypatch, paused_thread_name):
"""Pause one worker after acquisition and observe another waiting."""
from routes import email_routes
real_lock = email_routes._lock_email_account_owner_mutation
first_acquired = threading.Event()
release_first = threading.Event()
contender_attempted = threading.Event()
contender_acquired = threading.Event()
def controlled_lock(db, owner):
is_first = threading.current_thread().name == paused_thread_name
if not is_first:
contender_attempted.set()
real_lock(db, owner)
if is_first:
first_acquired.set()
assert release_first.wait(5), "timed out releasing first mutation"
else:
contender_acquired.set()
monkeypatch.setattr(
email_routes,
"_lock_email_account_owner_mutation",
controlled_lock,
)
return first_acquired, release_first, contender_attempted, contender_acquired
def test_concurrent_first_account_creates_choose_one_default(account_db, monkeypatch):
create_account = _endpoint("POST", "/api/email/accounts")
first_acquired, release_first, attempted, acquired = _install_lock_pause(
monkeypatch, "first-account"
)
results = {}
def create(name):
results[name] = asyncio.run(
create_account({"name": name, "is_default": False}, owner="alice")
)
first = threading.Thread(target=create, args=("First",), name="first-account")
second = threading.Thread(target=create, args=("Second",), name="second-account")
first.start()
assert first_acquired.wait(5)
second.start()
assert attempted.wait(5)
assert not acquired.wait(0.1), "second session bypassed the database mutation lock"
release_first.set()
first.join(5)
second.join(5)
assert not first.is_alive()
assert not second.is_alive()
assert results["First"]["ok"] is True
assert results["Second"]["ok"] is True
defaults = [row for row in _rows(account_db) if row[2]]
assert [(row[1], row[2]) for row in defaults] == [("alice", True)]
assert len(defaults) == 1
def test_delete_promotion_and_set_default_are_one_serial_transition(
account_db, monkeypatch
):
from sqlalchemy.orm import Session as OrmSession
_seed_account(account_db, "alice-a", "alice", is_default=True)
_seed_account(account_db, "alice-b", "alice")
_seed_account(account_db, "alice-c", "alice")
_seed_account(account_db, "bob-a", "bob", is_default=True)
delete_account = _endpoint("DELETE", "/api/email/accounts/{account_id}")
set_default = _endpoint("POST", "/api/email/accounts/{account_id}/set-default")
first_acquired, release_first, attempted, acquired = _install_lock_pause(
monkeypatch, "delete-default"
)
delete_commit_finished = threading.Event()
release_delete_after_commit = threading.Event()
real_commit = OrmSession.commit
results = {}
def pause_after_delete_commit(session):
real_commit(session)
if (
threading.current_thread().name == "delete-default"
and not delete_commit_finished.is_set()
):
delete_commit_finished.set()
assert release_delete_after_commit.wait(5), (
"timed out releasing delete after its first commit"
)
monkeypatch.setattr(OrmSession, "commit", pause_after_delete_commit)
def delete_old_default():
results["delete"] = asyncio.run(
delete_account("alice-a", owner="alice")
)
def select_new_default():
results["set"] = asyncio.run(
set_default("alice-c", owner="alice")
)
delete_thread = threading.Thread(target=delete_old_default, name="delete-default")
set_thread = threading.Thread(target=select_new_default, name="set-default")
delete_thread.start()
assert first_acquired.wait(5)
set_thread.start()
assert attempted.wait(5)
assert not acquired.wait(0.1), "set-default bypassed the delete transaction"
release_first.set()
assert delete_commit_finished.wait(5)
# The deletion transaction has committed. Let the contender complete
# before the deleting handler can continue: if promotion were still a
# second commit, it would now run after set-default and recreate two
# defaults deterministically.
assert acquired.wait(5)
set_thread.join(5)
release_delete_after_commit.set()
delete_thread.join(5)
assert not delete_thread.is_alive()
assert not set_thread.is_alive()
assert results == {"delete": {"ok": True}, "set": {"ok": True}}
assert _rows(account_db) == [
("alice-b", "alice", False),
("alice-c", "alice", True),
("bob-a", "bob", True),
]
def test_upgrade_normalizes_legacy_defaults_and_installs_unique_index(
tmp_path, monkeypatch
):
"""A pre-index schema upgrades without requiring newer account columns."""
from core import database as core_db
engine = create_engine(
f"sqlite:///{tmp_path / 'legacy-accounts.db'}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
try:
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE email_accounts (
id VARCHAR PRIMARY KEY,
owner VARCHAR,
name VARCHAR NOT NULL,
is_default BOOLEAN NOT NULL,
enabled BOOLEAN NOT NULL,
created_at DATETIME,
updated_at DATETIME
)
"""))
conn.execute(text("""
INSERT INTO email_accounts
(id, owner, name, is_default, enabled, created_at, updated_at)
VALUES
('legacy-old', NULL, 'Old', 1, 1, '2024-01-01', '2024-01-01'),
('legacy-new', '', 'New', 1, 1, '2025-01-01', '2025-01-01')
"""))
monkeypatch.setattr(core_db, "engine", engine)
core_db._migrate_email_account_default_invariant()
core_db._migrate_email_account_default_invariant() # idempotent replay
with engine.connect() as conn:
defaults = conn.execute(text("""
SELECT id FROM email_accounts
WHERE is_default IS TRUE
ORDER BY id
""")).scalars().all()
index_names = {
row[1] for row in conn.execute(text("PRAGMA index_list(email_accounts)"))
}
assert defaults == ["legacy-old"]
assert core_db._EMAIL_ACCOUNT_DEFAULT_INDEX in index_names
with pytest.raises(IntegrityError):
with engine.begin() as conn:
conn.execute(text("""
INSERT INTO email_accounts
(id, owner, name, is_default, enabled, created_at, updated_at)
VALUES
('legacy-third', NULL, 'Third', 1, 1, '2026-01-01', '2026-01-01')
"""))
finally:
engine.dispose()
def test_concurrent_legacy_seed_is_one_locked_transaction(
tmp_path, monkeypatch, caplog
):
from core import database as core_db
engine = create_engine(
f"sqlite:///{tmp_path / 'seed-accounts.db'}",
connect_args={"check_same_thread": False, "timeout": 5},
poolclass=NullPool,
)
core_db.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, autocommit=False, autoflush=False)
settings_file = tmp_path / "settings.json"
settings_file.write_text(
json.dumps({"imap_host": "imap.example.test", "imap_user": "alice"}),
encoding="utf-8",
)
monkeypatch.setattr(core_db, "engine", engine)
monkeypatch.setattr(core_db, "SessionLocal", factory)
monkeypatch.setattr(core_db, "SETTINGS_FILE", str(settings_file))
read_barrier = threading.Barrier(2)
real_read_text = Path.read_text
def synchronized_read(path, *args, **kwargs):
value = real_read_text(path, *args, **kwargs)
if path == settings_file:
read_barrier.wait(5)
return value
monkeypatch.setattr(Path, "read_text", synchronized_read)
threads = [
threading.Thread(target=core_db._migrate_seed_email_account)
for _ in range(2)
]
try:
for thread in threads:
thread.start()
for thread in threads:
thread.join(5)
assert all(not thread.is_alive() for thread in threads)
with engine.connect() as conn:
rows = conn.execute(text("""
SELECT owner, is_default FROM email_accounts
ORDER BY id
""")).all()
assert rows == [(None, 1)]
assert "seed email account migration:" not in caplog.text
finally:
engine.dispose()
def test_multi_owner_row_locks_are_acquired_in_canonical_order():
from core.database import lock_email_account_owner_mutations
class FakeSession:
def __init__(self):
self.locked = []
def get_bind(self):
return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
def get(self, _model, owner_key, **kwargs):
assert kwargs == {"with_for_update": True}
self.locked.append(owner_key)
return object()
db = FakeSession()
lock_email_account_owner_mutations(db, "zeta", "", "alpha", "zeta")
assert db.locked == ["", "alpha", "zeta"]
def test_postgresql_fresh_schema_emits_default_unique_index():
from core import database as core_db
statements = []
engine_holder = {}
def capture(statement, *_args, **_kwargs):
statements.append(
str(statement.compile(dialect=engine_holder["engine"].dialect))
)
mock_engine = create_mock_engine("postgresql://", capture)
engine_holder["engine"] = mock_engine
core_db.EmailAccount.__table__.create(mock_engine)
assert any(
core_db._EMAIL_ACCOUNT_DEFAULT_INDEX in statement
and "COALESCE(owner, '')" in statement
and "WHERE is_default IS TRUE" in statement
for statement in statements
)
def test_rename_serializes_old_and_new_owner_and_stale_set_default_fails_closed(
account_db, monkeypatch, tmp_path
):
from core import database as core_db
from routes import auth_routes
_seed_account(account_db, "alice-a", "alice", is_default=True)
_seed_account(account_db, "alice-b", "alice")
_seed_account(account_db, "bob-a", "bob", is_default=True)
prefs_module = types.ModuleType("routes.prefs_routes")
prefs_module._load = lambda: {}
prefs_module._save = lambda _data: None
monkeypatch.setitem(sys.modules, "routes.prefs_routes", prefs_module)
monkeypatch.setattr(
auth_routes, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research")
)
monkeypatch.setattr(auth_routes, "MEMORY_FILE", str(tmp_path / "memory.json"))
monkeypatch.setattr(auth_routes, "SKILLS_DIR", str(tmp_path / "skills"))
auth_manager = mock.MagicMock()
auth_manager.get_username_for_token.return_value = "admin"
auth_manager.is_admin.return_value = True
auth_manager.users = {"admin": {}, "alice": {}}
auth_manager.rename_user.return_value = True
rename_user = _named_endpoint(
auth_routes.setup_auth_routes(auth_manager), "rename_user"
)
set_default = _endpoint("POST", "/api/email/accounts/{account_id}/set-default")
rename_acquired = threading.Event()
release_rename = threading.Event()
set_attempted = threading.Event()
set_acquired = threading.Event()
real_lock = core_db.lock_email_account_owner_mutations
def controlled_lock(db, *owners):
thread_name = threading.current_thread().name
if thread_name == "rename-owner":
real_lock(db, *owners)
rename_acquired.set()
assert release_rename.wait(5)
return
if thread_name == "stale-set-default":
set_attempted.set()
real_lock(db, *owners)
set_acquired.set()
return
real_lock(db, *owners)
monkeypatch.setattr(core_db, "lock_email_account_owner_mutations", controlled_lock)
request = SimpleNamespace(
cookies={"odysseus_session": "admin-token"},
app=SimpleNamespace(
state=SimpleNamespace(
invalidate_token_cache=lambda: None,
session_manager=None,
research_handler=None,
upload_handler=None,
personal_docs_manager=None,
)
),
)
results = {}
def rename_owner():
results["rename"] = asyncio.run(
rename_user("alice", SimpleNamespace(username="bob"), request)
)
def select_stale_default():
try:
results["set"] = asyncio.run(
set_default("alice-b", owner="alice")
)
except Exception as exc: # asserted below with its HTTP status
results["set_error"] = exc
rename_thread = threading.Thread(target=rename_owner, name="rename-owner")
set_thread = threading.Thread(
target=select_stale_default, name="stale-set-default"
)
rename_thread.start()
assert rename_acquired.wait(5)
set_thread.start()
assert set_attempted.wait(5)
assert not set_acquired.wait(0.1), "set-default bypassed the rename lock"
release_rename.set()
rename_thread.join(5)
set_thread.join(5)
assert not rename_thread.is_alive()
assert not set_thread.is_alive()
assert results["rename"]["ok"] is True
assert isinstance(results["set_error"], HTTPException)
assert results["set_error"].status_code == 404
assert _rows(account_db) == [
("alice-a", "bob", False),
("alice-b", "bob", False),
("bob-a", "bob", True),
]
def test_demo_teardown_promotes_replacement_in_same_transaction(
account_db, monkeypatch
):
from core.database import EmailAccount
from scripts.demo_email import demo_account
db = account_db()
try:
db.add_all([
EmailAccount(
id="real",
owner="",
name="Real",
is_default=False,
enabled=True,
),
EmailAccount(
id="demo",
owner="",
name=demo_account.NAME,
imap_user=demo_account.IMAP_USER,
is_default=True,
enabled=True,
),
])
db.commit()
finally:
db.close()
monkeypatch.setattr(demo_account, "SessionLocal", account_db)
monkeypatch.setattr(demo_account, "engine", account_db.kw["bind"])
assert demo_account.teardown() == 0
assert _rows(account_db) == [("real", "", True)]
+414
View File
@@ -0,0 +1,414 @@
import json
from pathlib import Path
import shutil
import subprocess
import pytest
_REPO = Path(__file__).resolve().parents[1]
_EMAIL_LIBRARY = _REPO / "static" / "js" / "emailLibrary.js"
def _source() -> str:
return _EMAIL_LIBRARY.read_text(encoding="utf-8")
def _function_source(name: str) -> str:
"""Return one top-level JS function using balanced braces."""
text = _source()
markers = (f"function {name}", f"async function {name}", f"export function {name}", f"export async function {name}")
starts = [text.find(marker) for marker in markers]
starts = [start for start in starts if start >= 0]
assert starts, f"missing function {name}"
start = min(starts)
paren = text.index("(", start)
paren_depth = 0
quote = None
escaped = False
for index in range(paren, len(text)):
char = text[index]
if quote:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == quote:
quote = None
continue
if char in ("'", '"', "`"):
quote = char
elif char == "(":
paren_depth += 1
elif char == ")":
paren_depth -= 1
if paren_depth == 0:
brace = text.index("{", index)
break
else:
raise AssertionError(f"unterminated signature {name}")
depth = 0
quote = None
escaped = False
template_depth = 0
for index in range(brace, len(text)):
char = text[index]
if quote:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == quote and template_depth == 0:
quote = None
elif quote == "`" and char == "$" and index + 1 < len(text) and text[index + 1] == "{":
template_depth += 1
elif quote == "`" and char == "}" and template_depth:
template_depth -= 1
continue
if char in ("'", '"', "`"):
quote = char
elif char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return text[start:index + 1]
raise AssertionError(f"unterminated function {name}")
def _run_scheduler_scenario(scenario: str):
node = shutil.which("node")
if not node:
pytest.skip("node not on PATH")
functions = "\n".join(
_function_source(name)
for name in (
"_isChatInteractionBusy",
"_canRunEmailPrewarm",
"_isEmailPrewarmTemporarilyBlocked",
"_settleEmailPrewarm",
"_cancelEmailPrewarm",
"_scheduleEmailPrewarm",
)
)
script = f"""
let now = 0;
Date.now = () => now;
const state = {{ _libOpen: false, _libLoading: false }};
let _libSearchInFlight = false;
let _libPrewarmDelayTimer = null;
let _libPrewarmIdleHandle = null;
let _libPrewarmPromise = null;
let _libPrewarmResolve = null;
let _libPrewarmAbortController = null;
let _libPrewarmDetachPriorityListeners = null;
let _libPrewarmGeneration = 0;
let nextHandle = 1;
const timers = new Map();
const idleCallbacks = new Map();
let idleRequestCount = 0;
function eventTarget(target) {{
const listeners = new Map();
target.addEventListener = (type, callback) => {{
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type).add(callback);
}};
target.removeEventListener = (type, callback) => listeners.get(type)?.delete(callback);
target.dispatchEvent = (event) => {{
for (const callback of [...(listeners.get(event.type) || [])]) callback(event);
}};
target.listenerCount = (type) => listeners.get(type)?.size || 0;
return target;
}}
const document = eventTarget({{ visibilityState: 'visible' }});
const window = {{
__odysseusChatBusy: false,
__odysseusChatBusyUntil: 0,
requestIdleCallback(callback) {{
const handle = nextHandle++;
idleRequestCount += 1;
idleCallbacks.set(handle, callback);
return handle;
}},
cancelIdleCallback(handle) {{ idleCallbacks.delete(handle); }},
}};
eventTarget(window);
function setTimeout(callback, delay) {{
const handle = nextHandle++;
timers.set(handle, {{ callback, at: now + Number(delay || 0) }});
return handle;
}}
function clearTimeout(handle) {{ timers.delete(handle); }}
async function flushMicrotasks() {{
for (let i = 0; i < 6; i += 1) await Promise.resolve();
}}
async function advanceTo(target) {{
while (true) {{
const pending = [...timers.entries()]
.filter(([, timer]) => timer.at <= target)
.sort((a, b) => a[1].at - b[1].at)[0];
if (!pending) break;
const [handle, timer] = pending;
timers.delete(handle);
now = timer.at;
timer.callback();
await flushMicrotasks();
}}
now = target;
await flushMicrotasks();
}}
async function fireNextIdle(budget = 5) {{
const pending = idleCallbacks.entries().next().value;
if (!pending) throw new Error('no idle callback pending');
const [handle, callback] = pending;
idleCallbacks.delete(handle);
callback({{ didTimeout: false, timeRemaining: () => budget }});
await flushMicrotasks();
}}
{functions}
{scenario}
"""
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
return json.loads(proc.stdout.strip())
def test_prewarm_is_genuine_idle_only_and_single_flight():
scheduler = _function_source("_scheduleEmailPrewarm")
assert "if (_libPrewarmPromise) return _libPrewarmPromise;" in scheduler
assert "typeof window.requestIdleCallback !== 'function'" in scheduler
assert "return Promise.resolve(false);" in scheduler
assert "window.requestIdleCallback((deadline)" in scheduler
assert "!deadline.didTimeout" in scheduler
assert "deadline.timeRemaining() > 0" in scheduler
idle_callback = scheduler.index("window.requestIdleCallback((deadline)")
assert "Promise.resolve()" in scheduler
task_start = scheduler.index("task({ signal: controller.signal, generation })")
assert idle_callback < task_start, "network work must only be reachable from the idle callback"
def test_temporary_chat_priority_retries_one_single_flight_until_idle():
out = _run_scheduler_scenario("""
window.__odysseusChatBusyUntil = 10000;
let taskCalls = 0;
const task = async () => { taskCalls += 1; return true; };
const first = _scheduleEmailPrewarm(task, { delay: 1800 });
const joined = _scheduleEmailPrewarm(task, { delay: 0 });
const samePromise = first === joined;
await advanceTo(1800);
await fireNextIdle(7);
const callsWhileBusy = taskCalls;
while (now < 10300) {
await advanceTo(now + 500);
await fireNextIdle(7);
}
const result = await first;
console.log(JSON.stringify({
result, samePromise, callsWhileBusy, taskCalls, idleRequestCount,
timers: timers.size, idleCallbacks: idleCallbacks.size,
}));
""")
assert out == {
"result": True,
"samePromise": True,
"callsWhileBusy": 0,
"taskCalls": 1,
"idleRequestCount": 18,
"timers": 0,
"idleCallbacks": 0,
}
def test_cancelled_prewarm_cannot_issue_a_delayed_duplicate():
out = _run_scheduler_scenario("""
let taskCalls = 0;
const pending = _scheduleEmailPrewarm(async () => { taskCalls += 1; return true; }, { delay: 1800 });
await advanceTo(1400);
_cancelEmailPrewarm();
await advanceTo(12000);
const result = await pending;
console.log(JSON.stringify({
result, taskCalls, idleRequestCount,
timers: timers.size, idleCallbacks: idleCallbacks.size,
}));
""")
assert out == {
"result": False,
"taskCalls": 0,
"idleRequestCount": 0,
"timers": 0,
"idleCallbacks": 0,
}
@pytest.mark.parametrize("transition", ["busy", "hidden"])
def test_active_prewarm_is_aborted_and_retried_once_after_priority_transition(transition):
block = (
"window.__odysseusChatBusy = true; "
"window.dispatchEvent({ type: 'odysseus:chat-busy-change' });"
if transition == "busy"
else "document.visibilityState = 'hidden'; document.dispatchEvent({ type: 'visibilitychange' });"
)
unblock = (
"window.__odysseusChatBusy = false; window.__odysseusChatBusyUntil = now; "
"window.dispatchEvent({ type: 'odysseus:chat-busy-change' });"
if transition == "busy"
else "document.visibilityState = 'visible'; document.dispatchEvent({ type: 'visibilitychange' });"
)
out = _run_scheduler_scenario(f"""
let taskCalls = 0;
let firstSignal = null;
let finishFirst;
const firstAttempt = new Promise(resolve => {{ finishFirst = resolve; }});
const pending = _scheduleEmailPrewarm(async ({{ signal }}) => {{
taskCalls += 1;
if (taskCalls === 1) {{ firstSignal = signal; return firstAttempt; }}
return true;
}});
await fireNextIdle(7);
{block}
const aborted = firstSignal.aborted;
{unblock}
const callsBeforeLateResult = taskCalls;
finishFirst(true);
await flushMicrotasks();
const stillPendingAfterLateResult = _libPrewarmPromise === pending;
await advanceTo(now + 500);
await fireNextIdle(7);
const result = await pending;
console.log(JSON.stringify({{
result, aborted, callsBeforeLateResult, taskCalls,
stillPendingAfterLateResult,
timers: timers.size, idleCallbacks: idleCallbacks.size,
chatListeners: window.listenerCount('odysseus:chat-busy-change'),
visibilityListeners: document.listenerCount('visibilitychange'),
}}));
""")
assert out == {
"result": True,
"aborted": True,
"callsBeforeLateResult": 1,
"taskCalls": 2,
"stillPendingAfterLateResult": True,
"timers": 0,
"idleCallbacks": 0,
"chatListeners": 0,
"visibilityListeners": 0,
}
def test_prewarm_skips_hidden_and_foreground_work():
guard = _function_source("_canRunEmailPrewarm")
assert "state._libOpen" in guard
assert "state._libLoading" in guard
assert "_libSearchInFlight" in guard
assert "document.visibilityState !== 'visible'" in guard
assert "!_isChatInteractionBusy()" in guard
def test_prewarm_selects_only_last_used_or_default_account():
chooser = _function_source("_chooseEmailPrewarmAccountId")
prewarm = _function_source("_prewarmEmailViews")
assert "_rememberedEmailAccountId()" in chooser
assert "a.enabled !== false" in chooser
assert "a.is_default" in chooser
assert "enabled[0]" in chooser
assert "for (" not in prewarm
assert "orderedAccountIds" not in prewarm
assert "slice(0, 4)" not in prewarm
assert "/api/email/folders" not in prewarm
assert "/api/email/unread-state" not in prewarm
assert prewarm.count("/api/email/list") == 1
def test_prewarm_account_chooser_rejects_disabled_or_empty_authoritative_inventory():
node = shutil.which("node")
if not node:
pytest.skip("node not on PATH")
chooser = _function_source("_chooseEmailPrewarmAccountId")
script = f"""
const state = {{ _libAccountId: 'disabled-current' }};
function _rememberedEmailAccountId() {{ return 'disabled-remembered'; }}
{chooser}
const onlyDisabled = _chooseEmailPrewarmAccountId([
{{ id: 'disabled-remembered', enabled: false, is_default: true }},
{{ id: 'disabled-current', enabled: false }},
]);
const empty = _chooseEmailPrewarmAccountId([]);
const mixed = _chooseEmailPrewarmAccountId([
{{ id: 'disabled-remembered', enabled: false, is_default: true }},
{{ id: 'enabled-default', enabled: true, is_default: true }},
]);
console.log(JSON.stringify({{ onlyDisabled, empty, mixed }}));
"""
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
assert json.loads(proc.stdout.strip()) == {
"onlyDisabled": "",
"empty": "",
"mixed": "enabled-default",
}
ensure_accounts = _function_source("_ensureEmailAccountsForPrewarm")
assert "if (!accountId) return null;" in ensure_accounts
assert ensure_accounts.index("if (!accountId) return null;") < ensure_accounts.index("_publishActiveAccount();")
def test_prewarm_is_bounded_to_the_interactive_initial_page_size():
text = _source()
prewarm = _function_source("_prewarmEmailViews")
assert "const _LIB_INITIAL_PAGE_SIZE = 100;" in text
assert "limit: _LIB_INITIAL_PAGE_SIZE" in prewarm
assert text.count("limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}") == 2
assert "limit: 100" not in prewarm
def test_open_cancels_scheduled_or_inflight_prewarm_first():
text = _source()
cancel = _function_source("_cancelEmailPrewarm")
open_library = _function_source("openEmailLibrary")
assert "clearTimeout(_libPrewarmDelayTimer)" in cancel
assert "window.cancelIdleCallback(_libPrewarmIdleHandle)" in cancel
assert "_libPrewarmAbortController?.abort()" in cancel
assert "_libPrewarmGeneration += 1" in cancel
assert open_library.index("_cancelEmailPrewarm();") < open_library.index("state._libOpen = true;")
assert "_loadEmailsWhenChatIdle" not in text
assert text.count("_loadEmails({ useCache: true });") >= 2
def test_close_cancels_pending_prewarm_cleanup():
close_library = _function_source("closeEmailLibrary")
assert close_library.index("_cancelEmailPrewarm();") < close_library.index("state._libOpen = false;")
def test_unread_warm_joins_the_same_idle_single_flight_gate():
unread_entry = _function_source("prewarmUnreadEmails")
unread_work = _function_source("_prewarmUnreadEmailsNow")
assert "_scheduleEmailPrewarm(" in unread_entry
assert "fetch(" not in unread_entry
assert "_ensureEmailAccountsForPrewarm({ signal, generation })" in unread_work
assert "signal" in unread_work
assert "Math.min(20" in unread_work
+122 -2
View File
@@ -29,6 +29,7 @@ import base64
import json
import time
import unittest.mock as mock
from types import SimpleNamespace
import pytest
@@ -272,8 +273,14 @@ def _callback_endpoint():
class _FakeRequest:
"""Minimal stand-in for starlette Request — the callback only reads headers."""
headers = {"host": "localhost:7000"}
"""Minimal stand-in for starlette Request — the callback reads the Host header
and the request scheme. Behind a TLS terminator uvicorn's proxy-headers
middleware rewrites the scheme from `X-Forwarded-Proto`, so the route sees
`https` there and `http` on a plain origin."""
def __init__(self, scheme="http", host="localhost:7000"):
self.headers = {"host": host}
self.url = SimpleNamespace(scheme=scheme)
def _location(resp):
@@ -415,6 +422,119 @@ async def test_callback_valid_owner_writes_encrypted_tokens_to_intended_account(
assert other.oauth_access_token is None, "tokens must only touch the intended account"
# ── Redirect URI scheme ───────────────────────────────────────────
#
# Google rejects the token exchange unless the callback's `redirect_uri` is
# byte-identical to the one the authorize step sent, so both routes have to
# agree — including on the scheme. Deriving it from the request keeps HTTPS
# deployments working without pinning GOOGLE_OAUTH_REDIRECT_URI by hand;
# hardcoding `http://` produced an unusable redirect behind any TLS front.
def _authorize_endpoint():
"""Return the live google_oauth_authorize endpoint from the email router."""
from routes.email_routes import setup_email_routes
router = setup_email_routes()
for route in router.routes:
if route.path == "/api/email/oauth/google/authorize" and "GET" in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError("google_oauth_authorize route not found")
def _posted_redirect_uri(mock_post):
"""Pull `redirect_uri` out of the mocked Google token-exchange POST."""
return mock_post.call_args.kwargs["data"]["redirect_uri"]
@pytest.mark.asyncio
@pytest.mark.parametrize("scheme", ("http", "https"))
async def test_callback_redirect_uri_follows_the_request_scheme(scheme, monkeypatch):
"""The token exchange must echo the scheme the request actually arrived on —
`https` behind a TLS terminator, `http` on a plain origin."""
from routes.email_helpers import make_oauth_state
monkeypatch.delenv("GOOGLE_OAUTH_REDIRECT_URI", raising=False)
db, Factory = _make_db()
_make_account(db, account_id="acct-s", owner="alice", imap_user="alice@example.com")
db.close()
token_resp = mock.MagicMock()
token_resp.raise_for_status = mock.MagicMock()
token_resp.json.return_value = {"access_token": "ya29.t", "refresh_token": "1//r", "expires_in": 3600}
userinfo_resp = mock.MagicMock()
userinfo_resp.is_success = True
userinfo_resp.json.return_value = {"email": "alice@example.com", "name": "Alice"}
state = make_oauth_state("acct-s", "alice")
with mock.patch("httpx.post", return_value=token_resp) as mock_post, \
mock.patch("httpx.get", return_value=userinfo_resp), \
mock.patch("core.database.SessionLocal", Factory):
callback = _callback_endpoint()
await callback(
code="4/code", state=state, error=None,
request=_FakeRequest(scheme=scheme, host="odysseus.example.ts.net:7443"),
)
assert _posted_redirect_uri(mock_post) == (
f"{scheme}://odysseus.example.ts.net:7443/api/email/oauth/google/callback"
)
@pytest.mark.asyncio
async def test_callback_redirect_uri_env_override_still_wins(monkeypatch):
"""An explicit GOOGLE_OAUTH_REDIRECT_URI is used verbatim — deriving the
scheme must not override a value the operator pinned by hand."""
from routes.email_helpers import make_oauth_state
pinned = "https://mail.example.com/api/email/oauth/google/callback"
monkeypatch.setenv("GOOGLE_OAUTH_REDIRECT_URI", pinned)
db, Factory = _make_db()
_make_account(db, account_id="acct-p", owner="alice", imap_user="alice@example.com")
db.close()
token_resp = mock.MagicMock()
token_resp.raise_for_status = mock.MagicMock()
token_resp.json.return_value = {"access_token": "ya29.t", "refresh_token": "1//r", "expires_in": 3600}
userinfo_resp = mock.MagicMock()
userinfo_resp.is_success = True
userinfo_resp.json.return_value = {"email": "alice@example.com", "name": "Alice"}
state = make_oauth_state("acct-p", "alice")
with mock.patch("httpx.post", return_value=token_resp) as mock_post, \
mock.patch("httpx.get", return_value=userinfo_resp), \
mock.patch("core.database.SessionLocal", Factory):
callback = _callback_endpoint()
await callback(code="4/code", state=state, error=None, request=_FakeRequest(scheme="http"))
assert _posted_redirect_uri(mock_post) == pinned
@pytest.mark.asyncio
@pytest.mark.parametrize("scheme", ("http", "https"))
async def test_authorize_redirect_uri_follows_the_request_scheme(scheme, monkeypatch):
"""The authorize step builds the same redirect_uri the callback will send.
`owner=""` is the unconfigured / single-user case, so no DB is touched."""
import urllib.parse
monkeypatch.delenv("GOOGLE_OAUTH_REDIRECT_URI", raising=False)
monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_ID", "client-id.apps.googleusercontent.com")
authorize = _authorize_endpoint()
resp = await authorize(
account_id="acct-a",
request=_FakeRequest(scheme=scheme, host="odysseus.example.ts.net:7443"),
owner="",
)
query = urllib.parse.parse_qs(urllib.parse.urlparse(resp.headers["location"]).query)
assert query["redirect_uri"] == [
f"{scheme}://odysseus.example.ts.net:7443/api/email/oauth/google/callback"
]
@pytest.mark.asyncio
async def test_callback_rejects_token_for_a_different_mailbox_identity():
"""Reconnecting with another Google identity must not replace the token
@@ -0,0 +1,15 @@
"""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
@@ -0,0 +1,19 @@
"""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
+231
View File
@@ -0,0 +1,231 @@
"""Focused browser-side regression coverage for authoritative email opens."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_INBOX_JS = _REPO / "static" / "js" / "emailInbox.js"
_LIBRARY_JS = _REPO / "static" / "js" / "emailLibrary.js"
_HAS_NODE = shutil.which("node") is not None
def _extract_between(source: str, signature: str, next_marker: str) -> str:
start = source.index(signature)
end = source.index(next_marker, start)
return source[start:end].rstrip()
def test_library_unread_preview_has_one_authoritative_request_and_rollback():
source = _LIBRARY_JS.read_text(encoding="utf-8")
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
assert function.count("/api/email/read/") == 1
assert "/api/email/mark-read/" not in function
assert "&mark_seen=true" in function
assert "_syncEmailReadState(uidAtStart, true, readContext)" in function
assert "_syncEmailReadState(uidAtStart, false, readContext)" in function
assert "openGeneration === _emailCardOpenSeq" in function
assert "_emailReadMutations.get(readContextKey)?.generation !== readMutation.generation" in function
assert "authoritativeReadSucceeded = true;" in function
assert "if (!authoritativeReadSucceeded) restoreUnreadState();" in function
assert "if (!isCurrentOpen()) return" in function
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_library_authoritative_success_defeats_newer_rollback_in_either_order():
source = _LIBRARY_JS.read_text(encoding="utf-8")
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
settlements = _extract_between(
function,
" const restoreUnreadState = () => {",
"\n\n // Collapse any other expanded card",
)
harness = f"""
const _emailReadMutations = new Map();
const readContextKey = 'same-mailbox-message';
const uidAtStart = '1';
const readContext = {{ accountId: 'acct-a', folder: 'INBOX', uid: '1' }};
const readUpdates = [];
function _syncEmailReadState(uid, isRead, context) {{
readUpdates.push({{ uid, isRead, context }});
}}
function createSettlers(readMutation) {{
{settlements}
return {{ restoreUnreadState, commitReadState }};
}}
function runRace(successFirst) {{
_emailReadMutations.clear();
readUpdates.length = 0;
const mutationA = {{ generation: 1, rollbackUnread: true }};
_emailReadMutations.set(readContextKey, mutationA);
const settlersA = createSettlers(mutationA);
const mutationB = {{ generation: 2, rollbackUnread: true }};
_emailReadMutations.set(readContextKey, mutationB);
const settlersB = createSettlers(mutationB);
if (successFirst) {{
settlersA.commitReadState();
settlersB.restoreUnreadState();
}} else {{
settlersB.restoreUnreadState();
settlersA.commitReadState();
}}
return {{
hasMutation: _emailReadMutations.has(readContextKey),
readUpdates: readUpdates.map(update => update.isRead),
}};
}}
console.log(JSON.stringify({{
successFirst: runRace(true),
failureFirst: runRace(false),
}}));
"""
proc = subprocess.run(
["node", "--input-type=module"],
input=harness,
capture_output=True,
text=True,
cwd=str(_REPO),
timeout=30,
)
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
assert json.loads(proc.stdout.strip()) == {
"successFirst": {"hasMutation": False, "readUpdates": [True]},
"failureFirst": {"hasMutation": False, "readUpdates": [False, True]},
}
def test_library_reply_open_carries_immutable_mailbox_context():
library_source = _LIBRARY_JS.read_text(encoding="utf-8")
inbox_source = _INBOX_JS.read_text(encoding="utf-8")
assert "const mailboxGeneration = _emailMailboxGeneration;" in library_source
assert "messageFolder = String(options.email?.folder || libraryFolder)" in library_source
assert "return onEmailClick({ ...options, mailboxContext });" in library_source
assert "mailboxContext?.messageFolder || _currentFolder" in inbox_source
assert "mailboxContextIsCurrent()" in inbox_source
assert "if (!isCurrentOpen()) return;\n let activeSid = await _createEmailChat" in inbox_source
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_inbox_late_read_response_cannot_apply_after_newer_open():
source = _INBOX_JS.read_text(encoding="utf-8")
function = _extract_between(source, "async function _openEmail", "\nfunction _showEmailMenu")
assert "let _openEmailRequestSeq = 0;" in source
harness = f"""
const realLog = console.log;
console.error = () => {{}};
const API_BASE = 'https://odysseus.invalid';
const window = {{ __odysseusActiveEmailAccount: 'acct-a' }};
let _currentFolder = 'INBOX';
const _acct = () => '&account_id=acct-a';
let _openEmailRequestSeq = 0;
let _docModule = null;
const spinnerModule = {{ createWhirlpool() {{ throw new Error('spinner should not run'); }} }};
const sessionModule = null;
let firstResolve;
const calls = [];
async function fetch(url) {{
calls.push(String(url));
if (calls.length === 1) {{
return await new Promise((resolve) => {{
firstResolve = () => resolve({{ json: async () => ({{ uid: '1', subject: 'old' }}) }});
}});
}}
return {{ json: async () => ({{ error: 'newer open completed test' }}) }};
}}
{function}
const oldEmail = {{ uid: '1', is_read: false }};
const newerEmail = {{ uid: '2', is_read: false }};
const first = _openEmail(oldEmail, null);
await Promise.resolve();
const second = _openEmail(newerEmail, null);
await second;
firstResolve();
await first;
realLog(JSON.stringify({{ calls, oldRead: oldEmail.is_read, newerRead: newerEmail.is_read }}));
"""
proc = subprocess.run(
["node", "--input-type=module"],
input=harness,
capture_output=True,
text=True,
cwd=str(_REPO),
timeout=30,
)
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
result = json.loads(proc.stdout.strip())
assert len(result["calls"]) == 2
assert all("mark_seen=true" in url for url in result["calls"])
assert result["oldRead"] is False
assert result["newerRead"] is False
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
@pytest.mark.parametrize("context_change", ["account", "folder", "library"])
def test_inbox_late_read_response_cannot_apply_after_mailbox_switch(context_change):
source = _INBOX_JS.read_text(encoding="utf-8")
function = _extract_between(source, "async function _openEmail", "\nfunction _showEmailMenu")
changes = {
"account": "window.__odysseusActiveEmailAccount = 'acct-b';",
"folder": "_currentFolder = 'Archive';",
"library": "libraryCurrent = false;",
}
change = changes[context_change]
open_call = (
"_openEmail(email, null, null, 'reply', '', '', mailboxContext)"
if context_change == "library"
else "_openEmail(email, null)"
)
harness = f"""
const realLog = console.log;
console.error = () => {{}};
const API_BASE = 'https://odysseus.invalid';
const window = {{ __odysseusActiveEmailAccount: 'acct-a' }};
let _currentFolder = 'INBOX';
const _acct = () => '&account_id=acct-a';
let _openEmailRequestSeq = 0;
let libraryCurrent = true;
const mailboxContext = {{
accountId: 'acct-a',
messageFolder: 'Archive',
isCurrent: () => libraryCurrent,
}};
let createCalls = 0;
let _docModule = {{}};
async function _createEmailChat() {{ createCalls += 1; return 'stale-session'; }}
const spinnerModule = {{ createWhirlpool() {{ throw new Error('spinner should not run'); }} }};
const sessionModule = null;
let resolveRead;
async function fetch() {{
return await new Promise((resolve) => {{
resolveRead = () => resolve({{ json: async () => ({{ uid: '1', subject: 'old' }}) }});
}});
}}
{function}
const email = {{ uid: '1', is_read: false }};
const pending = {open_call};
await Promise.resolve();
{change}
resolveRead();
await pending;
realLog(JSON.stringify({{ createCalls, isRead: email.is_read }}));
"""
proc = subprocess.run(
["node", "--input-type=module"],
input=harness,
capture_output=True,
text=True,
cwd=str(_REPO),
timeout=30,
)
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
result = json.loads(proc.stdout.strip())
assert result == {"createCalls": 0, "isRead": False}
+278
View File
@@ -0,0 +1,278 @@
import asyncio
from contextlib import contextmanager
import pytest
RAW_EMAIL = (
b"From: Sender <sender@example.com>\r\n"
b"To: Alice <alice@example.com>\r\n"
b"Subject: Single authoritative open\r\n"
b"Message-ID: <single-open@example.com>\r\n"
b"Date: Tue, 04 Aug 2026 12:00:00 +0000\r\n"
b"Content-Type: text/plain; charset=utf-8\r\n"
b"\r\n"
b"Body"
)
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}")
class FakeImap:
def __init__(self, store_status="OK", readonly_mailbox=False):
self.store_status = store_status
# Shared archives and some provider folders reject a read-write SELECT.
self.readonly_mailbox = readonly_mailbox
self.selects = []
self.commands = []
def select(self, mailbox, readonly=False):
self.selects.append((mailbox, readonly))
if self.readonly_mailbox and not readonly:
raise OSError("[READ-ONLY] Mailbox is read-only")
return "OK", [b"1"]
def uid(self, command, uid, *args):
self.commands.append((command, uid, *args))
if command == "FETCH":
header, body = RAW_EMAIL.split(b"\r\n\r\n", 1)
return "OK", [
(b"1 (UID 42 BODY[HEADER])", header + b"\r\n\r\n"),
(b"1 (UID 42 BODY[TEXT]<0>)", body),
]
if command == "STORE":
# RFC 3501 STORE takes a parenthesized flag-list. GreenMail rejects
# the formerly emitted bare ``\Seen`` atom with BAD, so keep the
# fake strict enough to catch that provider-compatibility failure.
if args != ("+FLAGS", "(\\Seen)"):
return "BAD", [b"Expected:'(' found:'\\'"]
return self.store_status, []
raise AssertionError(f"unexpected IMAP command: {command}")
def _install_fakes(monkeypatch, tmp_path, *, store_status="OK", readonly_mailbox=False):
import routes.email_helpers as email_helpers
import routes.email_routes as email_routes
db_path = tmp_path / "email.db"
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
monkeypatch.setattr(email_routes, "SCHEDULED_DB", db_path)
email_helpers._init_scheduled_db()
connections = []
indexed_updates = []
@contextmanager
def fake_imap(account_id=None, owner=""):
conn = FakeImap(store_status=store_status, readonly_mailbox=readonly_mailbox)
connections.append(conn)
yield conn
monkeypatch.setattr(email_routes, "_start_poller", lambda: None)
monkeypatch.setattr(email_routes, "_imap", fake_imap)
monkeypatch.setattr(email_routes, "_email_preview_cache_get", lambda *_args, **_kwargs: None)
monkeypatch.setattr(email_routes, "_email_preview_cache_put", lambda *_args, **_kwargs: None)
monkeypatch.setattr(email_routes, "_email_attachment_meta_cache_get", lambda *_args, **_kwargs: None)
monkeypatch.setattr(email_routes, "_email_attachment_meta_cache_put", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
email_routes,
"_email_index_update_flags",
lambda *args, **_kwargs: indexed_updates.append(args),
)
return email_routes, connections, indexed_updates
@pytest.mark.asyncio
@pytest.mark.parametrize("mark_seen", [True, False])
async def test_read_email_seen_contract_uses_one_imap_connection(monkeypatch, tmp_path, mark_seen):
email_routes, connections, indexed_updates = _install_fakes(monkeypatch, tmp_path)
router = email_routes.setup_email_routes()
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
result = await read_email(
"42",
folder="INBOX",
account_id="acct-a",
mark_seen=mark_seen,
full=False,
owner="alice",
)
assert result["uid"] == "42"
assert len(connections) == 1
conn = connections[0]
assert conn.selects == [(conn.selects[0][0], not mark_seen)]
assert [command[0] for command in conn.commands] == (
["FETCH", "STORE"] if mark_seen else ["FETCH"]
)
assert "BODY.PEEK[HEADER]" in conn.commands[0][2]
if mark_seen:
assert conn.commands[1][2:] == ("+FLAGS", "(\\Seen)")
assert indexed_updates == [("alice", "acct-a", "INBOX", "42", "\\Seen", True)]
else:
assert indexed_updates == []
@pytest.mark.asyncio
async def test_cached_read_awaits_one_seen_store_without_refetch(monkeypatch, tmp_path):
email_routes, connections, indexed_updates = _install_fakes(monkeypatch, tmp_path)
router = email_routes.setup_email_routes()
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
first = await read_email(
"42", folder="INBOX", account_id="acct-a", mark_seen=False, full=False, owner="alice"
)
monkeypatch.setattr(
asyncio,
"create_task",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("cached mark-seen must be awaited, not scheduled")
),
)
second = await read_email(
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
)
assert first["message_id"] == second["message_id"]
assert len(connections) == 2
assert [command[0] for command in connections[0].commands] == ["FETCH"]
assert [command[0] for command in connections[1].commands] == ["STORE"]
assert connections[1].commands[0][2:] == ("+FLAGS", "(\\Seen)")
assert connections[1].selects[0][1] is False
assert indexed_updates == [("alice", "acct-a", "INBOX", "42", "\\Seen", True)]
@pytest.mark.asyncio
async def test_seen_store_failure_returns_the_body_and_reports_the_failure(monkeypatch, tmp_path):
"""A failed STORE must not cost the reader the message.
The body was fetched successfully before the flag update was attempted, so
the response stays a normal read and carries `mark_seen_failed` for the
client to roll its optimistic unread marker back.
"""
email_routes, connections, indexed_updates = _install_fakes(
monkeypatch, tmp_path, store_status="NO"
)
router = email_routes.setup_email_routes()
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
result = await read_email(
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
)
assert "error" not in result
assert result["uid"] == "42"
assert result["mark_seen_failed"] is True
assert len(connections) == 1
assert [command[0] for command in connections[0].commands] == ["FETCH", "STORE"]
# The local index must not claim a transition the provider rejected.
assert indexed_updates == []
@pytest.mark.asyncio
async def test_read_only_mailbox_serves_the_message_without_marking_seen(monkeypatch, tmp_path):
"""A mailbox that refuses a read-write SELECT is still readable.
Opening the message is the user's actual goal; the \\Seen transition is a
side effect of it. A folder that cannot accept flag changes must therefore
fall back to a read-only selection rather than failing the open.
"""
email_routes, connections, indexed_updates = _install_fakes(
monkeypatch, tmp_path, readonly_mailbox=True
)
router = email_routes.setup_email_routes()
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
result = await read_email(
"42", folder="Archive", account_id="acct-a", mark_seen=True, full=False, owner="alice"
)
assert "error" not in result
assert result["uid"] == "42"
assert result["mark_seen_failed"] is True
# Read-write attempt first, then the read-only retry on the same connection.
assert [readonly for _mailbox, readonly in connections[0].selects] == [False, True]
# No STORE is attempted once the mailbox is known to be read-only.
assert [command[0] for command in connections[0].commands] == ["FETCH"]
assert indexed_updates == []
@pytest.mark.asyncio
async def test_failed_seen_state_is_not_replayed_from_cache(monkeypatch, tmp_path):
"""`mark_seen_failed` describes one request, not the stored message.
A second read that does not ask to mark seen must come back clean, or every
later reader would inherit a STORE failure it never issued.
"""
email_routes, connections, _ = _install_fakes(monkeypatch, tmp_path, store_status="NO")
router = email_routes.setup_email_routes()
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
failed = await read_email(
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
)
replayed = await read_email(
"42", folder="INBOX", account_id="acct-a", mark_seen=False, full=False, owner="alice"
)
assert failed["mark_seen_failed"] is True
assert replayed.get("mark_seen_failed", False) is False
assert replayed["uid"] == "42"
@pytest.mark.asyncio
async def test_unparseable_read_does_not_mark_seen(monkeypatch, tmp_path):
email_routes, connections, indexed_updates = _install_fakes(monkeypatch, tmp_path)
monkeypatch.setattr(
email_routes.email_mod,
"message_from_bytes",
lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("malformed message")),
)
router = email_routes.setup_email_routes()
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
result = await read_email(
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
)
assert result == {"error": "Mail operation failed"}
assert len(connections) == 1
assert [command[0] for command in connections[0].commands] == ["FETCH"]
assert indexed_updates == []
@pytest.mark.asyncio
async def test_cached_seen_store_failure_returns_the_cached_body(monkeypatch, tmp_path):
"""A cache hit already holds a complete message; a failed STORE cannot take it away.
This is the path where withholding the body would be least defensible — the
response is served from memory and needed no network at all.
"""
email_routes, connections, indexed_updates = _install_fakes(
monkeypatch, tmp_path, store_status="NO"
)
router = email_routes.setup_email_routes()
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
first = await read_email(
"42", folder="INBOX", account_id="acct-a", mark_seen=False, full=False, owner="alice"
)
second = await read_email(
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
)
assert first["uid"] == "42"
assert "error" not in second
assert second["uid"] == "42"
assert second["body"] == first["body"]
assert second["mark_seen_failed"] is True
assert len(connections) == 2
assert [command[0] for command in connections[0].commands] == ["FETCH"]
assert [command[0] for command in connections[1].commands] == ["STORE"]
assert indexed_updates == []
+52
View File
@@ -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
+406
View File
@@ -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<<<SUMMARY>>>\n- Pay the invoice by Friday.\n<<<END>>>"
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 <billing@example.com>",
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 "<<<SUMMARY>>>\n- Used the fallback model.\n<<<END>>>"
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 <sender@example.com>",
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 <sender@example.com>",
"message_id": "<manual@example.com>",
"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=?",
("<manual@example.com>",),
).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 <sender@example.com>",
},
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 <sender@example.com>\r\n"
b"To: Alice <alice@example.com>\r\n"
b"Subject: Scheduled subject\r\n"
b"Message-ID: <scheduled@example.com>\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=?",
("<scheduled@example.com>",),
).fetchone()
finally:
conn.close()
assert row == ("alice", "- Scheduled summary", "gpt-5.5")
File diff suppressed because it is too large Load Diff
+277
View File
@@ -0,0 +1,277 @@
"""Regression coverage for strict foreground model selection."""
import asyncio
import json
from types import SimpleNamespace
import pytest
import src.agent_loop as agent_loop
import src.endpoint_resolver as endpoint_resolver
import src.foreground_model_routing as foreground_model_routing
import routes.chat_routes as chat_routes
from src.foreground_model_routing import (
build_foreground_model_candidates,
resolve_foreground_fallback_candidates,
)
def _collect(gen):
async def _run():
return [chunk async for chunk in gen]
return asyncio.run(_run())
class _EmptyQuery:
def filter(self, *args, **kwargs):
return self
def order_by(self, *args, **kwargs):
return self
def first(self):
return None
class _EmptyDb:
def query(self, *args, **kwargs):
return _EmptyQuery()
def close(self):
return None
class _RouteRequest:
def __init__(self, mode):
self.headers = {}
self.app = SimpleNamespace(state=SimpleNamespace(auth_manager=None))
self._form = {
"message": "hello",
"session": "session-1",
"mode": mode,
"compare_mode": "true",
}
async def form(self):
return self._form
def _chat_stream_endpoint(monkeypatch, mode, captured):
session = SimpleNamespace(
endpoint_url="https://selected.example/v1",
model="selected-model",
headers={"Authorization": "Bearer selected"},
name="test",
history=[],
add_message=lambda message: None,
)
session_manager = SimpleNamespace(
get_session=lambda session_id: session,
save_sessions=lambda: None,
)
context = SimpleNamespace(
user="alice",
messages=[{"role": "user", "content": "hello"}],
preprocessed=SimpleNamespace(attachment_meta=[]),
auto_opened_docs=[],
rag_sources=[],
web_sources=[],
used_memories=[],
uploaded_files=[],
uprefs={},
was_compacted=False,
context_trimmed=False,
context_length=4096,
context_messages_before_trim=1,
context_messages_after_trim=1,
context_tokens_before_trim=10,
context_tokens_after_trim=10,
preset=SimpleNamespace(temperature=0.2, max_tokens=128, character_name=None),
)
async def fake_build_context(*args, **kwargs):
return context
async def fake_chat_stream(candidates, messages, **kwargs):
captured["chat"] = candidates
yield f'data: {json.dumps({"delta": "done"})}\n\n'
yield "data: [DONE]\n\n"
async def fake_agent_stream(endpoint_url, model, messages, **kwargs):
captured["agent"] = {
"primary": (endpoint_url, model, kwargs.get("headers")),
"fallbacks": kwargs.get("fallbacks"),
}
yield f'data: {json.dumps({"delta": "done"})}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(chat_routes, "coerce_message_and_session", lambda *args, **kwargs: ("hello", "session-1"))
monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "effective_user", lambda request: "alice")
monkeypatch.setattr(chat_routes, "_clear_orphaned_session_endpoint", lambda *args, **kwargs: False)
monkeypatch.setattr(chat_routes, "_recover_empty_session_model", lambda *args, **kwargs: False)
monkeypatch.setattr(chat_routes, "_enforce_chat_privileges", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "resolve_session_auth", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "get_session_mode", lambda session_id: "chat")
monkeypatch.setattr(chat_routes, "set_session_mode", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "build_chat_context", fake_build_context)
monkeypatch.setattr(chat_routes, "SessionLocal", _EmptyDb)
monkeypatch.setattr(chat_routes, "_is_image_generation_session", lambda *args, **kwargs: False)
monkeypatch.setattr(chat_routes, "stream_llm_with_fallback", fake_chat_stream)
monkeypatch.setattr(chat_routes, "stream_agent_loop", fake_agent_stream)
monkeypatch.setattr(chat_routes, "save_assistant_response", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "run_post_response_tasks", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "estimate_tokens", lambda messages: 10)
monkeypatch.setattr(
endpoint_resolver,
"resolve_chat_fallback_candidates",
lambda owner=None: [("https://legacy.example/v1", "legacy-model", {})],
)
import src.settings as settings
monkeypatch.setattr(
settings,
"get_setting",
lambda key, default=None: default,
)
monkeypatch.setattr(
settings,
"get_user_setting",
lambda key, owner="", default=None: (
[{"endpoint_id": "legacy", "model": "legacy-model"}]
if key == "default_model_fallbacks"
else default
),
)
router = chat_routes.setup_chat_routes(
session_manager,
SimpleNamespace(),
SimpleNamespace(),
SimpleNamespace(),
SimpleNamespace(),
SimpleNamespace(),
)
return next(route.endpoint for route in router.routes if route.path == "/api/chat_stream")
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["chat", "agent"])
async def test_chat_stream_route_keeps_selected_model_strict_with_legacy_data(monkeypatch, mode):
captured = {}
endpoint = _chat_stream_endpoint(monkeypatch, mode, captured)
response = await endpoint(_RouteRequest(mode))
async for _ in response.body_iterator:
pass
selected = (
"https://selected.example/v1",
"selected-model",
{"Authorization": "Bearer selected"},
)
if mode == "chat":
assert captured == {"chat": [selected]}
else:
assert captured == {"agent": {"primary": selected, "fallbacks": []}}
def test_candidate_builder_appends_only_policy_authorized_fallbacks(monkeypatch):
"""Chat and Agent share the same candidate-building policy boundary."""
authorized = [("https://opt-in.example/v1", "opt-in-model", {})]
monkeypatch.setattr(
foreground_model_routing,
"resolve_foreground_fallback_candidates",
lambda owner=None: authorized,
)
assert build_foreground_model_candidates(
"https://selected.example/v1",
"selected-model",
{"Authorization": "Bearer selected"},
owner="alice",
) == [
("https://selected.example/v1", "selected-model", {"Authorization": "Bearer selected"}),
*authorized,
]
def test_strict_policy_builds_only_the_selected_chat_candidate():
candidates = build_foreground_model_candidates(
"https://selected.example/v1",
"selected-model",
{"Authorization": "Bearer selected"},
owner="alice",
)
assert candidates == [
("https://selected.example/v1", "selected-model", {"Authorization": "Bearer selected"})
]
def test_legacy_chat_resolver_is_disconnected():
assert endpoint_resolver.resolve_chat_fallback_candidates(owner="alice") == []
def test_utility_resolver_does_not_inherit_legacy_chat_fallbacks(monkeypatch):
seen_keys = []
def fake_resolve(setting_key, owner=None):
seen_keys.append((setting_key, owner))
return [("https://utility.example/v1", "utility-model", {})]
monkeypatch.setattr(endpoint_resolver, "_resolve_fallback_candidates", fake_resolve)
assert endpoint_resolver.resolve_utility_fallback_candidates(owner="alice") == [
("https://utility.example/v1", "utility-model", {})
]
assert seen_keys == [("utility_model_fallbacks", "alice")]
def test_multi_round_agent_uses_only_selected_model(monkeypatch):
"""Every Agent round receives only the selected foreground candidate."""
seen_candidates = []
round_number = 0
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
async def fake_stream(candidates, messages, **kwargs):
nonlocal round_number
round_number += 1
seen_candidates.append([(url, model) for url, model, _headers in candidates])
if round_number == 1:
call = {"name": "bash", "arguments": json.dumps({"command": "printf ok"})}
yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
else:
yield f'data: {json.dumps({"delta": "done"})}\n\n'
yield "data: [DONE]\n\n"
async def fake_execute(block, *args, **kwargs):
return "bash", {"output": "ok", "exit_code": 0}
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
fallbacks = resolve_foreground_fallback_candidates(owner="alice")
chunks = _collect(
agent_loop.stream_agent_loop(
"https://selected.example/v1",
"selected-model",
[{"role": "user", "content": "Run one tool and report back."}],
max_rounds=3,
relevant_tools={"bash"},
fallbacks=fallbacks,
_is_teacher_run=True,
)
)
assert seen_candidates == [
[("https://selected.example/v1", "selected-model")],
[("https://selected.example/v1", "selected-model")],
]
assert any('"delta": "done"' in chunk for chunk in chunks)
+5
View File
@@ -34,6 +34,11 @@ class _FakeSessionManager:
self.sessions = {"src-id": source}
self.created = None
def get_session(self, session_id):
# Fork looks the source up through get_session — the hydration seam —
# so a session only present in the DB still forks a real transcript.
return self.sessions[session_id]
def create_session(self, session_id=None, name=None, endpoint_url=None,
model=None, rag=False, owner=None):
self.created = _FakeSession(name=name, owner=owner)
+34
View File
@@ -0,0 +1,34 @@
import routes.gallery_routes as gallery_routes
class _TorchSentinel:
float32 = object()
float64 = object()
class _FakeTensor:
def __init__(self, dtype):
self.dtype = dtype
self.to_args = None
def to(self, *args, **kwargs):
self.to_args = (args, kwargs)
return self
def test_model_inputs_to_device_casts_mps_float64_to_float32():
float_tensor = _FakeTensor(_TorchSentinel.float64)
int_tensor = _FakeTensor("int64")
plain_value = object()
result = gallery_routes._model_inputs_to_device(
{"points": float_tensor, "labels": int_tensor, "plain": plain_value},
"mps",
_TorchSentinel,
)
assert result["points"] is float_tensor
assert float_tensor.to_args == ((), {"device": "mps", "dtype": _TorchSentinel.float32})
assert int_tensor.to_args == (("mps",), {})
assert result["plain"] is plain_value
+78
View File
@@ -0,0 +1,78 @@
"""Tools whose names collide with harmony built-ins must be aliased for gpt-oss.
gpt-oss (harmony format) ships BUILT-IN tools named `python` and `browser`,
invoked with the raw body as the argument (`to=python` + bare source), while
custom functions use `to=functions.NAME` + JSON. Exposing our own tool under a
built-in's name makes the model answer with the built-in convention: it emits
raw code, the server tries to parse it as JSON, and the request dies with
"error parsing tool call: raw='import sys, ...'". Streaming is worse — Ollama
truncates the stream instead of reporting it, so the turn looks like an empty
response and the agent loop reads it as a stall.
Measured on gpt-oss:20b via Ollama /v1 with a fixed agentic prompt:
python+bash as-is 2/6, python renamed 5/6, both renamed 6/6.
The aliasing is transport-only and gpt-oss-only: every other model's tool
schemas must pass through untouched, and real tool names must come back out.
"""
from src.llm_core import (
_alias_harmony_tools,
_unalias_harmony_tool_name,
_is_harmony_model,
)
def _tools(*names):
return [
{"type": "function", "function": {"name": n, "parameters": {}}}
for n in names
]
def _names(tools):
return [t["function"]["name"] for t in tools]
def test_gpt_oss_colliding_names_are_aliased():
out = _alias_harmony_tools(_tools("python", "bash", "web_search"), "gpt-oss:20b")
assert _names(out) == ["run_python_code", "run_shell_command", "web_search"]
def test_non_harmony_models_are_untouched():
tools = _tools("python", "bash", "web_search")
for model in ("qwen3-coder:30b", "gemma4:12b", "claude-opus-5", "gpt-4o", "llama-3.3"):
out = _alias_harmony_tools(tools, model)
assert _names(out) == ["python", "bash", "web_search"], model
assert out is tools, f"{model} should get the same list object, not a copy"
def test_aliasing_does_not_mutate_the_caller_list():
tools = _tools("python")
_alias_harmony_tools(tools, "gpt-oss:20b")
assert _names(tools) == ["python"], "caller's schema list must not be mutated"
def test_response_names_map_back_for_gpt_oss():
assert _unalias_harmony_tool_name("run_python_code", "gpt-oss:20b") == "python"
assert _unalias_harmony_tool_name("run_shell_command", "gpt-oss:20b") == "bash"
# Unrelated names pass through untouched.
assert _unalias_harmony_tool_name("web_search", "gpt-oss:20b") == "web_search"
def test_response_names_untouched_for_other_models():
# A non-harmony model that genuinely has a tool called run_python_code
# must not have it rewritten to `python`.
assert _unalias_harmony_tool_name("run_python_code", "qwen3-coder:30b") == "run_python_code"
def test_harmony_detection():
assert _is_harmony_model("gpt-oss:20b") is True
assert _is_harmony_model("GPT-OSS:120B") is True
assert _is_harmony_model("qwen3-coder:30b") is False
assert _is_harmony_model("") is False
assert _is_harmony_model(None) is False
def test_empty_and_none_tools_are_safe():
assert _alias_harmony_tools(None, "gpt-oss:20b") is None
assert _alias_harmony_tools([], "gpt-oss:20b") == []
+3 -3
View File
@@ -5,9 +5,9 @@ The in-memory branch skips messages whose metadata has ``hidden`` (e.g.
compaction summaries that are kept for AI context but not shown to the user).
The DB fallback (taken when the in-memory history is empty, e.g. after a
restart) built the client response from every DB row with no such filter, so
hidden messages leaked to the client on DB-served sessions. The rebuilt
in-memory ``session.history`` must still keep them, though, so only the response
is filtered.
hidden messages leaked to the client on DB-served sessions. Hydration of
``session.history`` belongs to ``get_session``; this fallback only shapes the
response, so only the response is filtered.
get_session_history depends on the DB, the session manager and a FastAPI
request, so this pins the regression at the source level (as other route tests
@@ -0,0 +1,549 @@
"""Display pagination must stay separate from full model-context hydration."""
import json
from datetime import datetime, timedelta
import pytest
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from starlette.requests import Request
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from core.database import Base, ChatMessage as DbChatMessage, Session as DbSession
from core.models import ChatMessage, Session
from core.session_manager import SessionManager
from routes import chat_routes
from routes.history import history_routes
from routes import session_routes
from src.request_models import ChatRequest
def _database():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(
engine,
tables=[DbSession.__table__, DbChatMessage.__table__],
)
return engine, sessionmaker(bind=engine, autocommit=False, autoflush=False)
def _seed_session(db_factory, *, session_id="session-1", message_count=6, stored_count=None):
"""Seed `message_count` real rows; `stored_count` overrides the denormalized
sessions.message_count column so drift can be reproduced."""
db = db_factory()
try:
db.add(
DbSession(
id=session_id,
name="Long chat",
endpoint_url="http://model.test/v1",
model="test-model",
owner="alice",
message_count=message_count if stored_count is None else stored_count,
)
)
start = datetime(2026, 1, 1, 12, 0, 0)
for index in range(message_count):
db.add(
DbChatMessage(
id=f"message-{index}",
session_id=session_id,
role="user" if index % 2 == 0 else "assistant",
content=f"content-{index}",
timestamp=start + timedelta(seconds=index),
)
)
db.commit()
finally:
db.close()
def _chat_message_selects(statements):
return [
" ".join(statement.lower().split())
for statement in statements
if statement.lstrip().lower().startswith("select")
and "chat_messages" in statement.lower()
]
def _manager(db_factory, monkeypatch, sessions=None):
"""A real SessionManager bound to the temp DB, with load counting."""
monkeypatch.setattr("core.session_manager.SessionLocal", db_factory)
manager = object.__new__(SessionManager)
manager.upload_handler = None
manager.sessions = sessions if sessions is not None else {}
manager.full_loads = 0
original_load = manager._load_session_from_db
def counting_load(session_id):
manager.full_loads += 1
return original_load(session_id)
manager._load_session_from_db = counting_load
return manager
def test_paginated_history_reads_only_count_and_requested_page(monkeypatch):
engine, db_factory = _database()
_seed_session(db_factory)
class DisplayOnlyManager:
def get_session(self, _session_id):
raise AssertionError("paginated display history must not hydrate model context")
monkeypatch.setattr(history_routes, "SessionLocal", db_factory)
monkeypatch.setattr(history_routes, "_verify_session_owner", lambda *_args: None)
app = FastAPI()
app.include_router(history_routes.setup_history_routes(DisplayOnlyManager()))
statements = []
def capture_sql(_conn, _cursor, statement, _parameters, _context, _executemany):
statements.append(statement)
event.listen(engine, "before_cursor_execute", capture_sql)
try:
response = TestClient(app).get("/api/history/session-1?limit=2")
finally:
event.remove(engine, "before_cursor_execute", capture_sql)
engine.dispose()
assert response.status_code == 200
payload = response.json()
assert [message["content"] for message in payload["history"]] == [
"content-4",
"content-5",
]
assert payload["total"] == 6
assert payload["offset"] == 4
assert payload["has_more_before"] is True
assert payload["has_more_after"] is False
# One COUNT for the total plus one page read — never a full-transcript
# select. The page bounds are asserted through the response above rather
# than by matching SQL text.
chat_selects = _chat_message_selects(statements)
assert len(chat_selects) == 2, chat_selects
assert sum("count(" in statement for statement in chat_selects) == 1
def test_production_router_order_reaches_bounded_canonical_history(monkeypatch):
"""The assembled app must not shadow canonical history with session routes."""
engine, db_factory = _database()
_seed_session(db_factory, message_count=1200)
class DisplayOnlyManager:
def get_session(self, _session_id):
raise AssertionError("bounded initial history must not hydrate all messages")
manager = DisplayOnlyManager()
monkeypatch.setattr(
session_routes,
"router",
APIRouter(prefix="/api", tags=["sessions"]),
)
monkeypatch.setattr(history_routes, "SessionLocal", db_factory)
monkeypatch.setattr(history_routes, "_verify_session_owner", lambda *_args: None)
app = FastAPI()
app.include_router(session_routes.setup_session_routes(manager, {}))
app.include_router(history_routes.setup_history_routes(manager))
try:
response = TestClient(app).get("/api/history/session-1?limit=24")
finally:
engine.dispose()
assert response.status_code == 200
assert response.request.url.params["limit"] == "24"
payload = response.json()
displayed = len(payload["history"])
assert 0 < displayed <= payload["limit"] <= 100
assert payload["total"] >= 1200
assert payload["has_more_before"] is True
assert displayed < payload["total"]
def test_incomplete_cached_history_hydrates_once_for_model_context(monkeypatch):
engine, db_factory = _database()
raw_multimodal = json.dumps(
[
{"type": "text", "text": "look at the source image"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,AAAA"},
},
]
)
db = db_factory()
try:
db.add(
DbSession(
id="session-1",
name="Long chat",
endpoint_url="http://model.test/v1",
model="test-model",
owner="alice",
message_count=3,
)
)
start = datetime(2026, 1, 1, 12, 0, 0)
db.add_all(
[
DbChatMessage(
id="message-0",
session_id="session-1",
role="user",
content=raw_multimodal,
meta_data=json.dumps(
{
"attachments": [
{
"id": "upload-1",
"filename": "source.png",
"content_type": "image/png",
}
]
}
),
timestamp=start,
),
DbChatMessage(
id="message-1",
session_id="session-1",
role="assistant",
content="answer",
timestamp=start + timedelta(seconds=1),
),
DbChatMessage(
id="message-2",
session_id="session-1",
role="system",
content="compaction summary",
meta_data=json.dumps({"hidden": True}),
timestamp=start + timedelta(seconds=2),
),
]
)
db.commit()
finally:
db.close()
manager = _manager(
db_factory,
monkeypatch,
sessions={
"session-1": Session(
id="session-1",
name="Long chat",
endpoint_url="http://model.test/v1",
model="test-model",
owner="alice",
history=[ChatMessage("user", "stale partial cache")],
# Deliberately stale too: get_session must refresh metadata before
# checking whether the cached transcript is complete.
message_count=1,
)
},
)
try:
hydrated = manager.get_session("session-1")
first_full_loads = manager.full_loads
warm = manager.get_session("session-1")
second_full_loads = manager.full_loads
finally:
engine.dispose()
assert hydrated is warm
assert len(hydrated.history) == 3
assert first_full_loads == 1
assert second_full_loads == first_full_loads
context = hydrated.get_context_messages()
assert len(context) == 3
assert context[0]["content"][1]["image_url"]["url"] == "data:image/png;base64,AAAA"
assert context[0]["metadata"]["attachments"] == [
{
"id": "upload-1",
"filename": "source.png",
"content_type": "image/png",
}
]
hidden_summary = next(message for message in context if message["role"] == "system")
assert hidden_summary["content"] == "compaction summary"
assert hidden_summary["metadata"]["hidden"] is True
def test_inflated_message_count_column_does_not_reload_warm_sessions(monkeypatch):
"""A drifted-high sessions.message_count must not reload on every read.
`_persist_message` swallows a failed insert while `add_message` has already
appended in memory, so the next successful persist writes rows+1. Keyed on
that column, the hydration gate would stay true forever and re-select the
whole transcript on every send, edit, delete and truncate.
"""
engine, db_factory = _database()
_seed_session(db_factory, message_count=6, stored_count=8)
manager = _manager(db_factory, monkeypatch)
try:
session = manager.get_session("session-1")
cold_loads = manager.full_loads
for _ in range(3):
manager.get_session("session-1")
finally:
engine.dispose()
assert len(session.history) == 6
assert cold_loads == 1
assert manager.full_loads == 1
def test_stale_low_message_count_column_still_hydrates_for_the_model(monkeypatch):
"""The other drift direction must not hand the model a truncated transcript.
`_persist_message` writes message_count = 0 when the session is not cached.
A partly-filled cache plus that stale-low column previously left the send
path with whatever RAM happened to hold.
"""
engine, db_factory = _database()
_seed_session(db_factory, message_count=6, stored_count=0)
manager = _manager(
db_factory,
monkeypatch,
sessions={
"session-1": Session(
id="session-1",
name="Long chat",
endpoint_url="http://model.test/v1",
model="test-model",
owner="alice",
history=[ChatMessage("user", "content-0")],
message_count=0,
)
},
)
try:
session = manager.get_session("session-1")
manager.get_session("session-1")
finally:
engine.dispose()
assert [message.content for message in session.history] == [
f"content-{index}" for index in range(6)
]
assert manager.full_loads == 1
def test_fork_after_restart_copies_the_real_transcript(monkeypatch):
"""Forking reads source.history, so it must hydrate through get_session.
Display pagination no longer fills the cache, so a fork taken after a
restart used to return HTTP 200 with an empty conversation.
"""
engine, db_factory = _database()
_seed_session(db_factory, message_count=6)
# Restart state: metadata-only cache entry, exactly what load_sessions seeds.
manager = _manager(db_factory, monkeypatch)
manager.load_sessions()
monkeypatch.setattr(history_routes, "SessionLocal", db_factory)
monkeypatch.setattr(history_routes, "_verify_session_owner", lambda *_args: None)
monkeypatch.setattr("core.models._SESSION_MANAGER_INSTANCE", manager)
app = FastAPI()
app.include_router(history_routes.setup_history_routes(manager))
client = TestClient(app)
try:
page = client.get("/api/history/session-1?limit=2")
assert page.status_code == 200
assert len(manager.sessions["session-1"].history) == 0
response = client.post("/api/session/session-1/fork", json={"keep_count": 4})
assert response.status_code == 200
payload = response.json()
assert payload["kept"] == 4
forked = manager.get_session(payload["id"])
assert [message.content for message in forked.history] == [
f"content-{index}" for index in range(4)
]
finally:
engine.dispose()
class _ContextBuildReached(Exception):
pass
class _ToolPolicy:
block_all_tool_calls = False
def blocks(self, _tool_name):
return False
class _ChatHandler:
async def handle_memory_command(self, _session, _message):
return None
def _json_request(path, payload):
raw = json.dumps(payload).encode()
sent = False
async def receive():
nonlocal sent
if sent:
return {"type": "http.request", "body": b"", "more_body": False}
sent = True
return {"type": "http.request", "body": raw, "more_body": False}
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": path,
"raw_path": path.encode(),
"root_path": "",
"query_string": b"",
"headers": [(b"content-type", b"application/json")],
"client": ("127.0.0.1", 1234),
"server": ("testserver", 80),
}
return Request(scope, receive)
def _route_endpoint(router, path):
return next(route.endpoint for route in router.routes if route.path == path)
@pytest.mark.asyncio
@pytest.mark.parametrize("path", ["/api/chat", "/api/chat_stream"])
async def test_model_send_routes_hydrate_before_context_build(monkeypatch, path):
# A real SessionManager over a real (temp) DB — a stub here would only
# assert that the stub hydrates, not that SessionManager does.
engine, db_factory = _database()
_seed_session(db_factory, message_count=6, stored_count=8)
manager = _manager(db_factory, monkeypatch)
manager.load_sessions() # restart state: metadata only, no messages cached
contexts_built = []
async def assert_complete_context(session, *_args, **_kwargs):
contexts_built.append(session)
assert [message.content for message in session.history] == [
f"content-{index}" for index in range(6)
]
raise _ContextBuildReached
monkeypatch.setattr(chat_routes, "_set_user_time_from_request", lambda *_args: None)
monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda *_args: None)
monkeypatch.setattr(chat_routes, "effective_user", lambda *_args: "alice")
monkeypatch.setattr(
chat_routes,
"_clear_orphaned_session_endpoint",
lambda *_args, **_kwargs: False,
)
monkeypatch.setattr(
chat_routes,
"_recover_empty_session_model",
lambda *_args, **_kwargs: False,
)
monkeypatch.setattr(chat_routes, "_enforce_chat_privileges", lambda *_args: None)
monkeypatch.setattr(
chat_routes,
"build_effective_tool_policy",
lambda **_kwargs: _ToolPolicy(),
)
monkeypatch.setattr(chat_routes, "build_chat_context", assert_complete_context)
monkeypatch.setattr(
chat_routes,
"_resolve_request_workspace",
lambda *_args: (None, False),
)
monkeypatch.setattr(chat_routes, "_classify_tool_intent", lambda *_args: None)
monkeypatch.setattr(
chat_routes,
"_is_contextual_web_followup",
lambda *_args: False,
)
monkeypatch.setattr(
chat_routes,
"_is_contextual_browser_followup",
lambda *_args: False,
)
monkeypatch.setattr(
chat_routes,
"_resolve_workspace_from_message_path",
lambda *_args: (None, None),
)
monkeypatch.setattr(
chat_routes,
"_reconcile_selected_route_from_request",
lambda *_args, **_kwargs: False,
)
monkeypatch.setattr(chat_routes, "resolve_session_auth", lambda *_args, **_kwargs: None)
monkeypatch.setattr(chat_routes, "get_session_mode", lambda *_args: "chat")
monkeypatch.setattr(
chat_routes,
"_is_image_generation_session",
lambda *_args, **_kwargs: False,
)
monkeypatch.setattr(chat_routes, "web_search_enabled_for_turn", lambda *_args: False)
router = chat_routes.setup_chat_routes(
manager,
_ChatHandler(),
object(),
object(),
object(),
object(),
)
endpoint = _route_endpoint(router, path)
async def send():
if path == "/api/chat":
await endpoint(
_json_request(path, {}),
ChatRequest(message="hello", session="session-1"),
)
else:
await endpoint(
_json_request(
path,
{"message": "hello", "session": "session-1"},
)
)
try:
with pytest.raises(_ContextBuildReached):
await send()
first_loads = manager.full_loads
# Second send on the now-warm session: the transcript is complete, so
# it must be served from RAM even though sessions.message_count is
# still drifted high in the DB.
with pytest.raises(_ContextBuildReached):
await send()
finally:
engine.dispose()
assert len(contexts_built) == 2
assert contexts_built[0] is contexts_built[1]
assert first_loads == 1
assert manager.full_loads == 1
+1 -1
View File
@@ -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
+240
View File
@@ -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"]
@@ -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
+86
View File
@@ -0,0 +1,86 @@
"""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",
},
}
]
+29
View File
@@ -0,0 +1,29 @@
"""The retired default fallback editor must not imply active routing."""
from pathlib import Path
from bs4 import BeautifulSoup
_REPO = Path(__file__).resolve().parents[1]
def test_legacy_default_fallback_editor_is_hidden():
soup = BeautifulSoup(
(_REPO / "static" / "index.html").read_text(encoding="utf-8"),
"html.parser",
)
editor = soup.find(id="set-defaultFallbacks")
assert editor is not None
assert editor.find_parent(class_="settings-row").has_attr("hidden")
def test_default_model_save_does_not_rewrite_legacy_fallbacks():
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
start = source.index("async function initDefaultChat()")
end = source.index("/* ── Utility Model ── */", start)
default_chat_source = source[start:end]
assert "settings.default_model_fallbacks" not in default_chat_source
assert "default_model_fallbacks:" not in default_chat_source
@@ -0,0 +1,145 @@
"""Source-level wiring guards for live-thinking stream lifecycle.
The pure scheduler suite covers timing behavior. These assertions pin the
browser-only integration seams that are impractical to import without the full
application DOM.
"""
from pathlib import Path
_CHAT = (Path(__file__).resolve().parent.parent / "static" / "js" / "chat.js").read_text(
encoding="utf-8"
)
def _between(start: str, end: str) -> str:
return _CHAT.split(start, 1)[1].split(end, 1)[0]
def test_in_thinking_delta_short_circuits_before_cumulative_normalization():
delta_handler = _between(
"let _delta = json.delta;",
"} else if (json.type === 'research_progress')",
)
delta_path = _between(
"// Detect thinking-in-progress:",
"} else if (json.type === 'research_progress')",
)
guard = "if (!_thinkingAnalysisGate.shouldAnalyze(roundText, {"
normalize = "markdownModule.normalizeThinkingMarkup(roundText)"
assert guard in delta_path
assert delta_path.index(guard) < delta_path.index(normalize)
assert "_queueLiveThinking(roundText);" in delta_path
assert "createThinkingAnalysisGate" in _CHAT
projector_append = "_roundDisplayProjector.append(_delta, roundText);"
assert projector_append in delta_handler
assert delta_handler.index(projector_append) < delta_handler.index(guard)
assert "_renderStream({ knownNormal: true, displayText: _roundDisplayProjector.current() });" in delta_path
assert "_replyDisplayProjector.append(_delta, roundReplyText)" in delta_path
def test_short_close_grace_expires_without_another_delta():
assert "function _scheduleThinkingGrace()" in _CHAT
grace = _between(
"function _scheduleThinkingGrace()",
"function _replyAfterClosedThinking",
)
assert "setTimeout(() =>" in grace
assert "_finishLiveThinkingTransition();" in grace
cancel = _between("_cancelLiveThinkingWork = () =>", "function _finalizeLiveThinking")
assert "_cancelThinkingGrace();" in cancel
delta_path = _between(
"// Detect thinking-in-progress:",
"} else if (json.type === 'research_progress')",
)
false_close = _between(
"// Detect false close:",
"if (hasUnclosedThink && !isThinking)",
)
assert "Do NOT require a prior unclosed delta" in false_close
assert "_afterClose &&" in false_close
assert "&& isThinking" not in false_close.split("let _falseCloseDeadline", 1)[1].split("if (isThinking)", 1)[0]
assert "_thinkingRecheckAt = _falseCloseDeadline || 0;" in delta_path
def test_terminal_paths_use_one_authoritative_rich_round_render():
tool_path = _between(
"} else if (json.type === 'tool_start') {",
"} else if (json.type === 'tool_output') {",
)
assert "_endLiveThinkingSection({ rich: false });" in tool_path
assert tool_path.count("_finalizeRoundRender();") == 1
assert "_renderStream();" not in tool_path
agent_path = _between(
"} else if (json.type === 'agent_step') {",
"} else if (json.type === 'budget_exceeded') {",
)
assert "_endLiveThinkingSection({ rich: false });" in agent_path
assert agent_path.count("_finalizeRoundRender();") == 1
assert "if (!roundFinalized)" not in agent_path
catch_path = _between(
"// foreground session's text.\n const _isBgCatch",
"} finally {",
)
assert "if (_isBgCatch)" in catch_path
assert "_cancelLiveThinkingWork();" in catch_path
assert "_catchTerminalView = _finalizeInterruptedView();" in catch_path
assert "_finalizeRoundRender();" not in catch_path
assert "_endThinkingOnTerminalPath({ rich: false });" in catch_path
assert "const _catchViewHolder = _catchTerminalView?.holder || holder;" in catch_path
round_finalizer = _between(
"_finalizeRoundRender = () => {",
"_finalizeInterruptedView = () => {",
)
assert "if (roundFinalized) return roundFinalization;" in round_finalizer
assert round_finalizer.index("processWithThinking") < round_finalizer.rindex("roundFinalized = true;")
assert "lastContentRoundHolder = terminalHolder;" in round_finalizer
interrupted_finalizer = _between(
"_finalizeInterruptedView = () => {",
"function _replyAfterClosedThinking",
)
assert "finalization?.hasContent" in interrupted_finalizer
assert "lastContentRoundHolder || finalization?.holder" in interrupted_finalizer
stop_path = _between(
"// Render whatever was accumulated so far",
"// Reset button state",
)
assert "const _stoppedViewHolder = _terminalView?.holder || currentHolder;" in stop_path
assert "_stoppedViewHolder.querySelector('.body').appendChild(stoppedIndicator);" in stop_path
done_path = _between(
"if (data === '[DONE]') {",
"try {\n const json = JSON.parse(data);",
)
assert "_finalizeLiveThinking(_closedThinkingText(roundText), false);" in done_path
assert "_renderStream();" not in done_path
post_loop = _between(
"if (!_streamSawDone) {",
"// --- Final render (skip if stream was ever backgrounded or currently in background) ---",
)
assert "_cancelLiveThinkingWork();" in post_loop
assert "_renderStream();" not in post_loop
recovery_path = _between(
"function _tryAutoRecover(holder, accumulated, sessionId)",
"function _removeStallBanner()",
)
assert "processWithThinking" not in recovery_path
def test_detach_synchronously_cancels_delayed_view_work():
registration = _between("_activeStreams.set(streamSessionId", "_syncForegroundStreamGlobals();")
assert "cancelViewWork: () => _cancelLiveThinkingWork()" in registration
detach = _between("export function detachCurrentStream", "// _notifyStreamComplete")
cancel = "if (active.cancelViewWork) active.cancelViewWork();"
background = "_backgroundStreams.set(sessionId"
assert cancel in detach
assert detach.index(cancel) < detach.index(background)
+29
View File
@@ -0,0 +1,29 @@
"""Runs the live-thinking throttle's behavioral suite under pytest.
Behavior lives in tests/live_thinking_scheduler.test.mjs (node:test, no DOM).
This wrapper only exists so the JS suite runs in the normal pytest job.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_HAS_NODE = shutil.which("node") is not None
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_live_thinking_scheduler_behavior():
result = subprocess.run(
["node", "--test", "tests/live_thinking_scheduler.test.mjs"],
cwd=_REPO,
capture_output=True,
timeout=30,
text=True,
)
if result.returncode != 0:
raise AssertionError(
f"node --test failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
)
+25 -1
View File
@@ -29,6 +29,13 @@ 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):
@@ -48,7 +55,10 @@ 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 — no opus-N-M pattern, kept
"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-haiku-4-5",
"claude-x",
"octopus-4-8", # "opus" only as a substring of another word — must not match
@@ -87,6 +97,20 @@ 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
@@ -0,0 +1,71 @@
"""Integration regression test for #5435.
llm_call_async must normalise Mistral structured content to a plain string,
matching llm_call (sync) and stream_llm. Before the fix, the async
non-streaming parser returned the raw list when Mistral reasoning was enabled,
violating its -> str contract, leaking a non-string into callers such as
auto-title generation and memory extraction, and poisoning _response_cache
with a non-string value.
"""
import asyncio
import src.llm_core as llm_core
class _FakeResponse:
is_success = True
status_code = 200
text = ""
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def _payload(content):
return {"choices": [{"message": {"role": "assistant", "content": content}}]}
def _call(monkeypatch, content):
async def fake_post(client, url, headers, **kwargs):
return _FakeResponse(_payload(content))
monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post)
llm_core._response_cache.clear()
return asyncio.run(llm_core.llm_call_async(
"http://mistral.test/v1/chat/completions",
"mistral-medium",
[{"role": "user", "content": "q"}],
))
def test_llm_call_async_normalizes_mistral_structured_content(monkeypatch):
out = _call(monkeypatch, [
{"type": "thinking",
"thinking": [{"type": "text", "text": "Let me work through this..."}],
"closed": True},
{"type": "text", "text": "The answer is 42."},
])
assert isinstance(out, str), f"expected str, got {type(out).__name__}"
assert "The answer is 42." in out
assert "Let me work through this..." in out
# The cache must hold the normalised string, not the raw list,
# otherwise repeat calls serve the poisoned value.
assert all(isinstance(v, str) for v in llm_core._response_cache.values())
def test_llm_call_async_thinking_only_still_returns_str(monkeypatch):
out = _call(monkeypatch, [
{"type": "thinking",
"thinking": [{"type": "text", "text": "still thinking"}],
"closed": True},
])
assert isinstance(out, str)
assert "still thinking" in out
def test_llm_call_async_plain_string_passthrough(monkeypatch):
out = _call(monkeypatch, "plain answer")
assert out == "plain answer"
+27
View File
@@ -0,0 +1,27 @@
"""Regression coverage for structured-thinking model detection."""
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import pytest
from src.llm_core import _supports_thinking
@pytest.mark.parametrize(
"model",
[
"deepseek-v4",
"deepseek-v4-flash",
"DeepSeek-V4-Flash",
"deepseek/deepseek-v4-flash",
],
)
def test_deepseek_v4_models_support_thinking(model):
assert _supports_thinking(model) is True
@pytest.mark.parametrize("model", ["deepseek-v3", "deepseek-chat"])
def test_other_deepseek_models_are_not_promoted_to_thinking(model):
assert _supports_thinking(model) is False
@@ -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,
}
+44
View File
@@ -214,6 +214,50 @@ def test_inline_code_content_is_html_escaped(node_available):
assert "<b>" 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 `$&amp;`.
html = _run_markdown_case(
"```sh\necho \"hello world\" | perl -pe 's/world/$& again/'\n```"
)
assert "___CODE_BLOCK_" not in html
assert "s/world/$&amp; again/" in html
assert "amp; again" not in html.replace("$&amp; 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/$&#39;/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 "$&amp;" 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
@@ -0,0 +1,15 @@
"""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
+19
View File
@@ -0,0 +1,19 @@
"""Regression test for the mcp route shim (slice 2o, #4082/#4071).
The backward-compat shim at ``routes/mcp_routes.py`` uses ``sys.modules``
replacement so the legacy import path and the canonical ``routes.mcp.*``
path resolve to the *same* module object. This is required because
``test_security_regressions.py`` does ``sys.modules.pop("routes.mcp_routes")``
+ re-import, ``monkeypatch.setattr(mcp_routes, "MCP_OAUTH_DIR", ...)``, and
reads ``mcp_routes.__file__`` for source introspection.
"""
import importlib
import routes.mcp_routes as _shim_mcp # noqa: F401
def test_legacy_and_canonical_mcp_module_are_same_object():
legacy = importlib.import_module("routes.mcp_routes")
canonical = importlib.import_module("routes.mcp.mcp_routes")
assert legacy is canonical
@@ -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("</div>", 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("<button", 0, btn_start):row.index(">", 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
@@ -67,6 +67,12 @@ 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]
@@ -0,0 +1,255 @@
"""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"]
+39 -10
View File
@@ -144,10 +144,10 @@ def test_get_default_chat_user_no_prefs_share_disabled_resolves_nothing(monkeypa
assert test_data["model"] == "", "Should get empty model"
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults_fallbacks(monkeypatch):
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults(monkeypatch):
"""
Non-admin user without personal preferences should resolve to global
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
defaults for ep_id and model when share_defaults_with_users is enabled.
"""
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True)
@@ -158,16 +158,45 @@ def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults_f
assert test_data["endpoint_id"] == "global-ep-123", \
"Should get global endpoint_id"
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults(monkeypatch):
def test_get_default_chat_does_not_read_legacy_fallbacks(monkeypatch):
"""
Non-admin user without personal preferences should resolve to global
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
The preserved legacy list must not influence default model resolution.
"""
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True, second_endpoint_only=True)
class LegacyReadGuard(dict):
def get(self, key, default=None):
if key == "default_model_fallbacks":
raise AssertionError("legacy fallback list was read")
return super().get(key, default)
assert test_data["model"] == "qwen-3.6", \
"model should be resolved from global default_model"
guarded_settings = LegacyReadGuard({
"default_endpoint_id": "global-ep-123",
"default_model": "qwen-3.6",
"default_model_fallbacks": [
{"endpoint_id": "fallback-ep", "model": "fallback-model"}
],
"share_defaults_with_users": True,
})
monkeypatch.setattr(model_routes, "_load_settings", lambda: guarded_settings)
monkeypatch.setattr(prefs_routes, "_load_for_user", lambda user: LegacyReadGuard({}))
assert test_data["endpoint_id"] == "fallback-ep", \
"Should get global endpoint_id"
fake_auth_manager = MagicMock()
fake_auth_manager.is_admin = lambda user: False
endpoint = _FakeEndpoint(
id="global-ep-123",
base_url="http://global-endpoint:8000/v1",
is_enabled=True,
)
fake_db = _make_db_session([endpoint], user="regular_user")
monkeypatch.setattr(model_routes, "SessionLocal", lambda: fake_db)
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url)
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat")
router = model_routes.setup_model_routes(model_discovery=None)
get_default_chat = _get_default_chat_route(router)
fake_request = _make_request(user="regular_user", auth_manager=fake_auth_manager)
test_data = get_default_chat(fake_request)
assert test_data["endpoint_id"] == "global-ep-123"
assert test_data["model"] == "qwen-3.6"
+1 -1
View File
@@ -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_routes.py", "ai_tidy_documents")
body = _function_source("routes/document/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
+7 -6
View File
@@ -90,7 +90,7 @@ def test_clear_speech_endpoint_settings_resets_tts_and_stt():
}
def test_endpoint_cleanup_removes_primary_and_fallback_references():
def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
settings = {
"default_endpoint_id": "dead",
"default_model": "primary",
@@ -106,14 +106,12 @@ def test_endpoint_cleanup_removes_primary_and_fallback_references():
assert _endpoint_settings_using_endpoint(settings, "dead", include_speech=True) == [
"Default Model",
"Default Model Fallbacks",
"Utility Model Fallbacks",
"Vision Model Fallbacks",
"Speech to Text",
]
assert _clear_endpoint_settings_for_endpoint(settings, "dead", include_speech=True) == [
"Default Model",
"Default Model Fallbacks",
"Utility Model Fallbacks",
"Vision Model Fallbacks",
"Speech to Text",
@@ -121,6 +119,7 @@ def test_endpoint_cleanup_removes_primary_and_fallback_references():
assert settings["default_endpoint_id"] == ""
assert settings["default_model"] == ""
assert settings["default_model_fallbacks"] == [
{"endpoint_id": "dead", "model": "fallback-a"},
{"endpoint_id": "keep", "model": "fallback-b"},
]
assert settings["utility_model_fallbacks"] == []
@@ -129,7 +128,7 @@ def test_endpoint_cleanup_removes_primary_and_fallback_references():
assert settings["stt_model"] == "base"
def test_endpoint_cleanup_updates_scoped_and_legacy_user_prefs():
def test_endpoint_cleanup_updates_active_scoped_prefs_but_preserves_legacy_data():
scoped = {
"_users": {
"alice": {
@@ -154,8 +153,10 @@ def test_endpoint_cleanup_updates_scoped_and_legacy_user_prefs():
legacy = {
"default_model_fallbacks": [{"endpoint_id": "dead", "model": "chat"}],
}
assert _clear_user_pref_endpoint_refs(legacy, "dead") == 1
assert legacy["default_model_fallbacks"] == []
assert _clear_user_pref_endpoint_refs(legacy, "dead") == 0
assert legacy["default_model_fallbacks"] == [
{"endpoint_id": "dead", "model": "chat"}
]
# ── _default_endpoint_needs_assignment (add-endpoint auto-default) ──
+3 -2
View File
@@ -1,11 +1,12 @@
import json
import routes.prefs_routes as prefs_routes
from core import atomic_io
def test_save_replaces_prefs_file_atomically(monkeypatch, tmp_path):
calls = []
real_replace = prefs_routes.os.replace
real_replace = atomic_io.os.replace
def fake_replace(src, dst):
calls.append((src, dst))
@@ -13,7 +14,7 @@ def test_save_replaces_prefs_file_atomically(monkeypatch, tmp_path):
prefs_file = tmp_path / "data" / "user_prefs.json"
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
monkeypatch.setattr(prefs_routes.os, "replace", fake_replace)
monkeypatch.setattr(atomic_io.os, "replace", fake_replace)
prefs_routes._save({"theme": "dark"})
+12
View File
@@ -114,6 +114,12 @@ def _force_sql_owner_migration_failure(monkeypatch):
def filter(self, *_args, **_kwargs):
return self
def order_by(self, *_args, **_kwargs):
return self
def all(self):
return []
def update(self, *_args, **_kwargs):
raise RuntimeError("forced owner migration failure")
@@ -125,6 +131,12 @@ def _force_sql_owner_migration_failure(monkeypatch):
def query(self, _model):
return FailingQuery()
def get_bind(self):
return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
def get(self, _model, _key, **_kwargs):
return object()
def rollback(self):
self.rolled_back = True
+11
View File
@@ -0,0 +1,11 @@
"""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
+1 -1
View File
@@ -998,7 +998,7 @@ def test_session_html_export_escapes_name():
def test_mcp_oauth_page_escapes_reflected_values():
src = Path(__file__).resolve().parents[1] / "routes" / "mcp_routes.py"
src = Path(__file__).resolve().parents[1] / "routes" / "mcp" / "mcp_routes.py"
text = src.read_text()
body = text.split("def _oauth_authorize_page(", 1)[1].split("return f", 1)[0]
for var in ("auth_url", "server_id", "host", "redirect_uri"):
+58
View File
@@ -0,0 +1,58 @@
"""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()
@@ -0,0 +1,214 @@
"""Regression for issue #5210 — SKILL.md frontmatter scalars must round-trip.
``_emit_scalar`` quotes a scalar with ``json.dumps`` whenever it contains
punctuation that would change how the line reads back. ``_parse_scalar`` used
to undo that with a bare ``raw[1:-1]``: it stripped the quotes but never
decoded the escapes. So ``"Pr\\u00fcfung"`` was read back as the literal text
``Pr\\u00fcfung``, and the next save escaped *that* backslash again.
The damage compounds — each save doubles the backslash run — so a German or
Japanese skill description degrades into backslash noise after a handful of
edits, and the same happens to a plain-ASCII description that merely contains
a quote character. The escapes are also shown verbatim in the skills list and
the ``/skills`` catalog.
The fix is symmetric: emit with ``ensure_ascii=False`` (SKILL.md is UTF-8 at
both ends) and parse double-quoted scalars with ``json.loads``.
"""
import json
import pytest
from services.memory.skill_format import (
Skill,
_emit_scalar,
_parse_scalar,
parse_frontmatter,
)
from services.memory.skills import SkillsManager
# Umlauts plus a comma — the comma is what forces the quoted form, which is the
# only path that was corrupted. Reported verbatim in issue #5210.
GERMAN = "Einstiegs- und Pr\u00fcfungslinie f\u00fcr AGB, Datenschutz"
JAPANESE = "\u30b9\u30ad\u30eb: \u30c6\u30b9\u30c8\u7528\u306e\u8aac\u660e"
QUOTED_ASCII = 'Use the "grep" tool, then summarise'
def _cycle(skill: Skill, times: int = 1) -> Skill:
"""Save to markdown and read it straight back, `times` times over."""
for _ in range(times):
skill = Skill.from_markdown(skill.to_markdown())
return skill
# ---------------------------------------------------------------------------
# The reported corruption
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"description",
[GERMAN, JAPANESE, QUOTED_ASCII],
ids=["german", "japanese", "quoted-ascii"],
)
def test_description_survives_repeated_saves(description):
"""Five load/save cycles must leave the text byte-identical.
One cycle is enough to corrupt it; five is where the doubling became
obvious in the field.
"""
result = _cycle(Skill(name="demo", description=description), times=5)
assert result.description == description
def test_corruption_does_not_compound_across_saves():
"""Pin the *growth*, not just the mismatch.
The original defect was not a one-off mangling — the escaped form was
re-escaped on every save, so the value grew without bound. A regression
that reintroduced single-level mangling would still be caught by the test
above; this one catches the runaway specifically.
"""
skill = Skill(name="demo", description=GERMAN)
lengths = []
for _ in range(5):
skill = _cycle(skill)
lengths.append(len(skill.description))
assert len(set(lengths)) == 1, f"description length drifted across saves: {lengths}"
# ---------------------------------------------------------------------------
# What actually lands on disk
# ---------------------------------------------------------------------------
def test_non_ascii_is_written_as_utf8_not_ascii_escapes():
"""SKILL.md is opened as UTF-8 at both ends, so \\uXXXX buys nothing and
only makes the file unreadable to a human editing it."""
markdown = Skill(name="demo", description=GERMAN).to_markdown()
line = next(l for l in markdown.splitlines() if l.startswith("description:"))
assert "Pr\u00fcfungslinie" in line
assert "\\u00fc" not in line
def test_quoted_scalar_is_valid_json():
"""The emitted form is what the parser now feeds to json.loads, so the two
halves cannot drift apart without this failing."""
emitted = _emit_scalar(QUOTED_ASCII)
assert json.loads(emitted) == QUOTED_ASCII
# ---------------------------------------------------------------------------
# Existing files
# ---------------------------------------------------------------------------
def test_legacy_ascii_escaped_file_is_read_correctly():
"""Files already written by the old emitter hold real JSON escapes, so the
new parser recovers the intended text instead of the escape source."""
markdown = '---\nname: demo\ndescription: "Pr\\u00fcfung, x"\n---\n\n'
assert Skill.from_markdown(markdown).description == "Pr\u00fcfung, x"
def test_already_corrupted_file_heals_one_level_per_load():
"""A file that took one round of damage carries a doubled backslash. That
is still valid JSON, so reading it yields the single-backslash form and the
value stops degrading."""
markdown = '---\nname: demo\ndescription: "Pr\\\\u00fcfung, x"\n---\n\n'
once = Skill.from_markdown(markdown)
assert once.description == "Pr\\u00fcfung, x"
# And it is now stable rather than growing on every subsequent save.
assert _cycle(once, times=3).description == "Pr\\u00fcfung, x"
def test_non_json_escape_falls_back_to_literal_reading():
"""A hand-written frontmatter value can hold escapes JSON rejects (a bare
Windows path is the common one). Those must keep their previous literal
reading rather than raising."""
assert _parse_scalar('"C:\\Users\\demo"') == "C:\\Users\\demo"
# ---------------------------------------------------------------------------
# Unchanged behaviour
# ---------------------------------------------------------------------------
def test_plain_scalars_are_still_emitted_bare():
"""Only values needing quotes get them — the common case must not suddenly
start quoting, which would churn every SKILL.md on disk."""
assert _emit_scalar("open-pr-from-branch") == "open-pr-from-branch"
assert _emit_scalar("1.0.0") == "1.0.0"
assert _emit_scalar(True) == "true"
assert _emit_scalar(None) == "null"
assert _emit_scalar(0.8) == "0.8"
def test_single_quoted_scalar_keeps_literal_reading():
"""Only double-quoted scalars are JSON. Single-quoted ones are read the way
they always were."""
assert _parse_scalar("'plain, text'") == "plain, text"
def test_lists_round_trip_with_non_ascii_entries():
skill = Skill(name="demo", tags=["b\u00fcro", "recht, steuern"])
assert _cycle(skill, times=3).tags == ["b\u00fcro", "recht, steuern"]
# ---------------------------------------------------------------------------
# Line-break characters
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"sep",
["\u2028", "\u2029", "\x85", "\r", "\v", "\f", "\x1c"],
ids=["ls", "ps", "nel", "cr", "vt", "ff", "fs"],
)
def test_line_break_characters_do_not_split_the_frontmatter(sep):
"""parse_frontmatter() reads one scalar per line via str.splitlines(),
which breaks on far more than \\n. Any of these landing unescaped in the
file would silently truncate the value and shift the remainder into a
bogus key.
json.dumps covers the C0 ones, but with ensure_ascii=False it passes NEL,
LINE SEPARATOR and PARAGRAPH SEPARATOR through as themselves — so those
three are re-escaped explicitly.
"""
description = f"before{sep}after, x"
markdown = Skill(name="demo", description=description).to_markdown()
frontmatter_text = markdown.split("---")[1]
assert len(frontmatter_text.strip().splitlines()) == len(
[l for l in frontmatter_text.strip().split("\n") if l.strip()]
), "a scalar leaked a line break into the frontmatter"
fm, _body = parse_frontmatter(markdown)
assert fm["description"] == description
# ---------------------------------------------------------------------------
# End to end, through real files
# ---------------------------------------------------------------------------
def test_description_survives_real_save_load_cycles_on_disk(tmp_path):
"""The unit tests above go straight through to_markdown/from_markdown.
This drives the same path the app does — SkillsManager writing UTF-8 files
with atomic_write_text and reading them back — because the encoding used at
either end is part of the fix.
"""
manager = SkillsManager(str(tmp_path))
manager.add_skill(name="agb-pruefung", description=GERMAN, category="general")
for _ in range(4):
assert manager.update_skill("agb-pruefung", {"status": "published"})
stored = [s for s in manager.load_all() if s["name"] == "agb-pruefung"]
assert len(stored) == 1
assert stored[0]["description"] == GERMAN
on_disk = (tmp_path / "skills" / "general" / "agb-pruefung" / "SKILL.md").read_text(
encoding="utf-8"
)
assert GERMAN in on_disk
@@ -0,0 +1,319 @@
"""Pin the self-termination contract of the canvas spinners in static/js/spinner.js.
Background: the whirlpool spinner drives itself with requestAnimationFrame and
decides whether to keep going by looking at `element.isConnected`. It used to
re-arm forever whenever the element had *never* been connected, on the theory
that start() runs before the caller appends the element. Callers that start a
spinner and then take an early return - an aborted request, a panel that
resolved from cache before the loading row was inserted - therefore left a rAF
loop redrawing an 84-segment spiral into a detached canvas until the tab closed.
Measured on an idle app: ~110 whirlpool frames per second with zero canvases in
the document.
These tests lock in all four exits (never attached, attached-then-removed,
stop(), tab hidden) and, just as importantly, the one case that must NOT stop:
a spinner that is actually on screen.
Driven through `node --input-type=module` so the real module runs, same idiom as
test_esc_menu_stack_js.py. The module source is inlined rather than imported by
path because the repo has no `"type": "module"` in package.json; spinner.js has
no imports of its own, so inlining is exact. A fake clock and a manual frame
pump replace performance.now()/requestAnimationFrame, so nothing here depends on
wall-clock time or real frame timing.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_MODULE = _REPO / "static" / "js" / "spinner.js"
_HAS_NODE = shutil.which("node") is not None
_SRC = _MODULE.read_text(encoding="utf-8") if _MODULE.exists() else ""
# Browser stand-ins, installed before the module body runs. `clock` is advanced
# only by pump(), so every timing decision in the module is deterministic.
_STUBS = r"""
let clock = 0;
Object.defineProperty(globalThis, 'performance', {
value: { now: () => clock }, configurable: true, writable: true,
});
const pending = new Map();
let nextFrameId = 1;
let framesRun = 0;
globalThis.requestAnimationFrame = (cb) => {
const id = nextFrameId++;
pending.set(id, cb);
return id;
};
globalThis.cancelAnimationFrame = (id) => { pending.delete(id); };
/** Advance the clock `steps` frames of `msPerFrame` and run whatever is queued. */
function pump(steps, msPerFrame = 16) {
for (let i = 0; i < steps; i++) {
clock += msPerFrame;
const due = [...pending.values()];
pending.clear();
for (const cb of due) { framesRun++; cb(); }
}
}
function framesPending() { return pending.size; }
function framesSince(mark) { return framesRun - mark; }
function frameMark() { return framesRun; }
function makeCtx() {
const noop = () => {};
return {
clearRect: noop, beginPath: noop, arc: noop, moveTo: noop, lineTo: noop,
stroke: noop, fill: noop, save: noop, restore: noop,
strokeStyle: '', fillStyle: '', lineWidth: 0, globalAlpha: 1,
lineCap: '', lineJoin: '',
};
}
function makeElement(tag) {
const el = {
tagName: tag, className: '', textContent: '', innerHTML: '',
width: 0, height: 0, isConnected: false, parentNode: null,
style: { cssText: '' },
children: [],
classList: { add: () => {}, remove: () => {}, contains: () => false },
getContext: () => makeCtx(),
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
},
removeChild(child) {
this.children = this.children.filter((c) => c !== child);
child.parentNode = null;
return child;
},
};
return el;
}
const docListeners = [];
globalThis.document = {
hidden: false,
documentElement: makeElement('html'),
createElement: makeElement,
createTextNode: (t) => ({ textContent: t }),
addEventListener: (type, fn) => { docListeners.push([type, fn]); },
removeEventListener: (type, fn) => {
const i = docListeners.findIndex(([t, f]) => t === type && f === fn);
if (i >= 0) docListeners.splice(i, 1);
},
};
globalThis.getComputedStyle = () => ({ getPropertyValue: () => '' });
function visibilityListeners() {
return docListeners.filter(([t]) => t === 'visibilitychange').length;
}
function fireVisibility(hidden) {
document.hidden = hidden;
for (const [t, fn] of [...docListeners]) if (t === 'visibilitychange') fn();
}
/** A started whirlpool spinner whose element is not in the document. */
function startedWhirlpool() {
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.start();
return sp;
}
"""
def _run(body: str) -> dict:
"""Run `body` with the real spinner module and the browser stubs in scope."""
js = _STUBS + "\n" + _SRC + "\n" + body
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, encoding="utf-8",
cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_never_attached_whirlpool_stops_itself():
# The leak: element created, spinner started, element never inserted. Past
# the grace window it must give up rather than re-arm forever.
body = """
const sp = startedWhirlpool();
pump(30); // 480 ms - inside the grace window
const early = { running: sp.isRunning, pending: framesPending() };
pump(120); // ~2.4 s total - past the grace window
const mark = frameMark();
pump(60); // nothing should be left to run
console.log(JSON.stringify({
early,
running: sp.isRunning,
rafId: sp.rafId,
pending: framesPending(),
framesAfterStop: framesSince(mark),
}));
"""
out = _run(body)
assert out["early"] == {"running": True, "pending": 1}, "gave up during the grace window"
assert out["running"] is False
assert out["rafId"] is None
assert out["pending"] == 0
assert out["framesAfterStop"] == 0, "loop kept drawing after it gave up"
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_attached_whirlpool_keeps_running_past_the_grace_window():
# The converse guard: the fix must not kill spinners that are on screen.
body = """
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.element.isConnected = true;
sp.start();
pump(400); // ~6.4 s, far past the grace window
const mark = frameMark();
pump(10);
console.log(JSON.stringify({
running: sp.isRunning,
pending: framesPending(),
framesDrawn: framesSince(mark),
}));
"""
out = _run(body)
assert out["running"] is True
assert out["pending"] == 1
assert out["framesDrawn"] == 10, "a visible spinner stopped animating"
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_attached_then_removed_whirlpool_stops():
# The pre-existing exit - a loading row replaced by results - still works.
body = """
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.element.isConnected = true;
sp.start();
pump(200);
const whileAttached = sp.isRunning;
sp.element.isConnected = false; // results arrived, row swapped out
pump(3);
const mark = frameMark();
pump(20);
console.log(JSON.stringify({
whileAttached,
running: sp.isRunning,
pending: framesPending(),
framesAfterRemoval: framesSince(mark),
}));
"""
out = _run(body)
assert out["whileAttached"] is True
assert out["running"] is False
assert out["pending"] == 0
assert out["framesAfterRemoval"] == 0
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_loading_row_helper_stops_when_the_row_is_never_inserted():
# createLoadingRow() starts the spinner for the caller and hands back a
# detached row, so a caller that early-returns is the real leak shape.
body = """
const row = createLoadingRow('Loading...', 16);
pump(200);
const mark = frameMark();
pump(40);
console.log(JSON.stringify({
pending: framesPending(),
framesAfterStop: framesSince(mark),
rowHasChildren: row.children.length > 0,
}));
"""
out = _run(body)
assert out["rowHasChildren"] is True, "harness built the wrong row"
assert out["pending"] == 0
assert out["framesAfterStop"] == 0
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_stop_cancels_the_pending_frame_and_releases_the_listener():
# stop() must be authoritative: no queued frame survives it, and it leaves
# no visibilitychange listener behind on a dead spinner.
body = """
const before = visibilityListeners();
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.element.isConnected = true;
sp.start();
const armed = visibilityListeners();
sp.stop();
const mark = frameMark();
pump(20);
console.log(JSON.stringify({
before, armed, after: visibilityListeners(),
running: sp.isRunning,
rafId: sp.rafId,
pending: framesPending(),
framesAfterStop: framesSince(mark),
}));
"""
out = _run(body)
assert (out["before"], out["armed"], out["after"]) == (0, 1, 0)
assert out["running"] is False
assert out["rafId"] is None
assert out["pending"] == 0
assert out["framesAfterStop"] == 0
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_hidden_tab_pauses_frames_and_showing_resumes_them():
body = """
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.element.isConnected = true;
sp.start();
pump(5);
fireVisibility(true);
const hiddenMark = frameMark();
pump(30);
const whileHidden = { drawn: framesSince(hiddenMark), pending: framesPending() };
fireVisibility(false);
const shownMark = frameMark();
pump(10);
console.log(JSON.stringify({
whileHidden,
running: sp.isRunning,
drawnAfterShow: framesSince(shownMark),
}));
"""
out = _run(body)
assert out["whileHidden"] == {"drawn": 0, "pending": 0}, "kept drawing in a hidden tab"
assert out["running"] is True
assert out["drawnAfterShow"] == 10, "did not resume when the tab came back"
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_restarted_spinner_gets_a_fresh_grace_window():
# The grace deadline is per-run. A spinner reused after stop() must not
# inherit the previous run's timestamp and die on its first frame.
body = """
const sp = startedWhirlpool();
pump(200); // times out, never attached
const stopped = sp.isRunning;
sp.element.isConnected = true; // now inserted for real
sp.start();
pump(30);
console.log(JSON.stringify({
stopped,
running: sp.isRunning,
pending: framesPending(),
}));
"""
out = _run(body)
assert out["stopped"] is False
assert out["running"] is True
assert out["pending"] == 1
+356
View File
@@ -0,0 +1,356 @@
"""Exercise sessions.js and startupShell.js together at the bootstrap seam.
The dependency-heavy session module is copied unchanged except for redirecting
its static imports to tiny browser stubs. The real loadSessions implementation
and the real startup-shell coordinator then run together under Node.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_SESSIONS = _REPO / "static" / "js" / "sessions.js"
_SHELL_URL = (_REPO / "static" / "js" / "startupShell.js").as_uri()
_HAS_NODE = shutil.which("node") is not None
_IMPORT_REWRITES = {
"import Storage from './storage.js';": "import Storage from './storage.mjs';",
"import uiModule, { autoResize, styledPrompt } from './ui.js';": (
"import uiModule, { autoResize, styledPrompt } from './ui.mjs';"
),
"import chatRenderer from './chatRenderer.js?v=20260722ctxheader1';": (
"import chatRenderer from './chatRenderer.mjs';"
),
"import { providerLogo } from './providers.js';": (
"import { providerLogo } from './providers.mjs';"
),
"import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';": (
"import { initModelPicker, updateModelPicker } from './modelPicker.mjs';"
),
"import themeModule from './theme.js';": "import themeModule from './theme.mjs';",
"import spinnerModule from './spinner.js';": "import spinnerModule from './spinner.mjs';",
}
_STUBS = {
"storage.mjs": r"""
const Storage = {
get: (key, fallback = null) => localStorage.getItem(key) ?? fallback,
set: (key, value) => localStorage.setItem(key, value),
remove: (key) => localStorage.removeItem(key),
getJSON: (key, fallback) => {
try { return JSON.parse(localStorage.getItem(key) ?? JSON.stringify(fallback)); }
catch (_) { return fallback; }
},
setJSON: (key, value) => localStorage.setItem(key, JSON.stringify(value)),
};
export default Storage;
""",
"ui.mjs": r"""
export const autoResize = () => {};
export const styledPrompt = async () => null;
const ui = {
el: (id) => document.getElementById(id),
showError: (message) => globalThis.__sessionErrors.push(String(message)),
showToast: () => {},
styledConfirm: async () => true,
};
export default ui;
""",
"chatRenderer.mjs": (
"export default { addMessage: () => null, hideWelcomeScreen: () => {} };\n"
),
"providers.mjs": "export const providerLogo = () => '';\n",
"modelPicker.mjs": (
"export const initModelPicker = () => {};\n"
"export const updateModelPicker = () => {};\n"
),
"theme.mjs": "export default {};\n",
"spinner.mjs": "export default {};\n",
}
_HARNESS = r"""
const SESSIONS_URL = 'SESSIONS_PATH';
const SHELL_URL = 'SHELL_PATH';
function makeStore() {
const values = new Map();
return {
getItem(key) { return values.has(key) ? values.get(key) : null; },
setItem(key, value) { values.set(key, String(value)); },
removeItem(key) { values.delete(key); },
};
}
function makeClassList() {
const values = new Set();
return {
add(...names) { names.forEach(name => values.add(name)); },
remove(...names) { names.forEach(name => values.delete(name)); },
contains(name) { return values.has(name); },
toggle(name, force) {
const enabled = force === undefined ? !values.has(name) : !!force;
if (enabled) values.add(name); else values.delete(name);
return enabled;
},
};
}
function makeWorld() {
const byId = new Map();
const frames = [];
const cancelledFrames = new Set();
const timers = [];
let nextFrame = 1;
let historyWrites = 0;
function makeElement(id = '') {
let html = '';
const element = {
id,
dataset: {},
style: {},
classList: makeClassList(),
children: [],
status: null,
value: '',
disabled: false,
removed: false,
addEventListener() {},
removeEventListener() {},
setAttribute(name, value) { this[name] = value; },
getAttribute(name) { return this[name] ?? null; },
appendChild(child) { this.children.push(child); return child; },
insertBefore(child) { this.children.unshift(child); return child; },
contains() { return false; },
closest() { return null; },
querySelector(selector) {
if (selector === '[data-session-list-status]') return this.status;
return null;
},
querySelectorAll() { return []; },
focus() { document.activeElement = this; },
remove() { this.removed = true; if (this.id) byId.delete(this.id); },
};
Object.defineProperty(element, 'innerHTML', {
get() { return html; },
set(value) {
html = String(value);
if (id === 'session-list' && html === '') {
const row = byId.get('session-list-loading');
if (row) row.remove();
}
},
});
return element;
}
const document = {
activeElement: null,
getElementById: (id) => byId.get(id) || null,
querySelector: () => null,
querySelectorAll: () => [],
createElement: (tag) => makeElement(tag),
createDocumentFragment: () => makeElement('fragment'),
addEventListener() {},
};
globalThis.document = document;
globalThis.localStorage = makeStore();
globalThis.sessionStorage = makeStore();
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'Linux' },
configurable: true,
});
globalThis.history = { replaceState() { historyWrites += 1; } };
globalThis.window = {
document,
innerWidth: 1024,
innerHeight: 768,
location: { origin: 'http://odysseus.test', hash: '', pathname: '/', href: '/' },
addEventListener() {},
removeEventListener() {},
chatModule: {
detachCurrentStream() {},
showWelcomeScreen() {},
},
__odysseusDefaultChat: {
endpoint_url: 'http://model.test',
model: 'test/model',
endpoint_id: 'endpoint-1',
},
};
globalThis.location = window.location;
globalThis.requestAnimationFrame = (fn) => {
const id = nextFrame++;
frames.push({ id, fn });
return id;
};
globalThis.cancelAnimationFrame = (id) => cancelledFrames.add(id);
globalThis.setTimeout = (fn, ms) => { timers.push({ fn, ms }); return timers.length; };
globalThis.clearTimeout = () => {};
globalThis.__sessionErrors = [];
return {
add(id, options = {}) {
const element = makeElement(id);
if (options.statusText !== undefined) {
element.status = { textContent: options.statusText };
}
if (options.value !== undefined) element.value = options.value;
byId.set(id, element);
return element;
},
paint(rounds = 1) {
for (let i = 0; i < rounds; i += 1) {
const due = frames.splice(0, frames.length);
for (const frame of due) {
if (!cancelledFrames.has(frame.id)) frame.fn();
}
}
},
runTimers() {
const due = timers.splice(0, timers.length);
for (const timer of due) timer.fn();
},
byId,
historyWrites: () => historyWrites,
resetHistoryWrites: () => { historyWrites = 0; },
};
}
const world = makeWorld();
world.add('session-list');
world.add('sessions-section');
const message = world.add('message', { value: 'draft before seed' });
const responses = [
{
ok: true,
status: 200,
json: async () => [{ id: 'existing', name: 'Existing', folder: 'Assistant', archived: false }],
},
{
ok: false,
status: 503,
json: async () => ({ detail: 'temporarily unavailable' }),
},
];
let fetchCount = 0;
globalThis.fetch = async () => {
fetchCount += 1;
const response = responses.shift();
if (!response) throw new Error('unexpected fetch');
return response;
};
const sessions = await import(SESSIONS_URL + '?bootstrap');
const shell = await import(SHELL_URL + '?bootstrap');
const seeded = await sessions.loadSessions();
world.paint(1);
localStorage.setItem('lastSessionId', 'existing');
message.value = 'draft must survive';
document.activeElement = null;
world.resetHistoryWrites();
const loader = world.add('app-loader');
const row = world.add('session-list-loading', { statusText: 'Loading chats…' });
let opened = 0;
shell.deferRouteOpener('/email', () => { opened += 1; });
const hydrated = await shell.settleSessionHydration(() => sessions.loadSessions());
const beforePaint = row.status.textContent;
world.paint(2);
world.runTimers();
const staleRouteRan = shell.runDeferredRouteOpener({ sessionsSettled: true });
const errorsBeforeAuth = __sessionErrors.length;
globalThis.fetch = async () => {
fetchCount += 1;
const response = { ok: false, status: 401, json: async () => ({ detail: 'expired' }) };
window.location.href = '/login'; // app.js global fetch-wrapper behaviour
return response;
};
const authResult = await sessions.loadSessions();
console.log(JSON.stringify({
seeded,
hydrated,
beforePaint,
afterPaint: row.status.textContent,
rowStillPresent: world.byId.has('session-list-loading'),
loaderRemoved: loader.removed,
opened,
staleRouteRan,
fetchCount,
sessionIds: sessions.getSessions().map(session => session.id),
pendingChat: sessions.hasPendingChat(),
draft: message.value,
lastSessionId: localStorage.getItem('lastSessionId'),
historyWrites: world.historyWrites(),
errors: __sessionErrors,
authResult,
authRedirect: window.location.href,
authAddedError: __sessionErrors.length !== errorsBeforeAuth,
}));
"""
@pytest.fixture(scope="module")
def results(tmp_path_factory):
if not _HAS_NODE:
pytest.skip("node is not installed")
module_dir = tmp_path_factory.mktemp("session-bootstrap-js")
source = _SESSIONS.read_text(encoding="utf-8")
for original, replacement in _IMPORT_REWRITES.items():
assert original in source, f"sessions import changed: {original}"
source = source.replace(original, replacement, 1)
sessions_module = module_dir / "sessions.mjs"
sessions_module.write_text(source, encoding="utf-8")
for name, stub in _STUBS.items():
(module_dir / name).write_text(stub, encoding="utf-8")
harness = _HARNESS.replace("SESSIONS_PATH", sessions_module.as_uri()).replace(
"SHELL_PATH", _SHELL_URL
)
proc = subprocess.run(
["node", "--input-type=module", "-e", harness],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, f"node harness failed:\n{proc.stderr}"
return json.loads(proc.stdout.strip().splitlines()[-1])
def test_fulfilled_503_is_not_applied_as_an_empty_session_list(results):
assert results["seeded"] is True
assert results["hydrated"] is False
assert results["sessionIds"] == ["existing"]
assert results["pendingChat"] is False, "failure created a default direct chat"
assert results["draft"] == "draft must survive"
assert results["lastSessionId"] == "existing"
assert results["historyWrites"] == 0
def test_fulfilled_503_keeps_failure_state_and_route_deferred(results):
assert results["beforePaint"] == "Loading chats…"
assert results["afterPaint"] == "Chats unavailable"
assert results["rowStillPresent"] is True
assert results["loaderRemoved"] is True
assert results["opened"] == 0
assert results["staleRouteRan"] is False
assert results["errors"] == [
"Failed to load sessions: temporarily unavailable",
]
def test_401_keeps_global_auth_redirect_contract(results):
assert results["authResult"] is False
assert results["authRedirect"] == "/login"
assert results["authAddedError"] is False
assert results["sessionIds"] == ["existing"]
+377
View File
@@ -0,0 +1,377 @@
"""Pin the startup shell contract (static/js/startupShell.js).
Driven through `node --input-type=module` against a stub DOM and a manually
pumped frame/timer clock, so the real module runs without a browser (same
approach as test_composer_arrow_up_recall_js.py). Skips when `node` is absent.
Locks in the behaviour #5926 asks for: the shell is revealed one paint after
wiring and does not wait on /api/sessions; the loader node survives hydration
as a startup sentinel but is always retired once hydration settles; the sidebar
owns its own loading/failure row and a successful zero-session render never
shows a false failure; and a URL route opens only after the data it actually
needs is authoritatively available.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_MODULE = _REPO / "static" / "js" / "startupShell.js"
_MODULE_URL = _MODULE.as_uri()
_HAS_NODE = shutil.which("node") is not None
_HARNESS = r"""
const MODULE_URL = 'MODULE_PATH';
// ── Stub DOM + a clock we pump by hand ────────────────────────────────────
function makeWorld() {
const byId = new Map();
const frames = [];
const timers = [];
const world = {
byId,
waveStops: 0,
addElement(id, { statusText = null } = {}) {
const el = {
id,
dataset: {},
style: {},
attrs: {},
removed: false,
status: null,
setAttribute(k, v) { this.attrs[k] = v; },
getAttribute(k) { return this.attrs[k]; },
remove() { this.removed = true; byId.delete(this.id); },
querySelector(sel) {
return sel === '[data-session-list-status]' ? this.status : null;
},
};
if (statusText !== null) el.status = { textContent: statusText };
byId.set(id, el);
return el;
},
// One "paint" = one round of already-queued rAF callbacks. afterNextPaint
// chains two, so a committed paint takes two rounds.
paint(rounds = 1) {
for (let i = 0; i < rounds; i++) {
const due = frames.splice(0, frames.length);
for (const fn of due) fn();
}
},
runTimers() {
const due = timers.splice(0, timers.length);
for (const t of due) t.fn();
},
pendingTimers() { return timers.length; },
};
globalThis.document = { getElementById: (id) => byId.get(id) || null };
globalThis.window = { __odysseusLoaderWaveStop: () => { world.waveStops += 1; } };
globalThis.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };
globalThis.setTimeout = (fn, ms) => { timers.push({ fn, ms }); return timers.length; };
return world;
}
// Fresh module instance per case so deferred-route state cannot leak.
let _instance = 0;
async function loadModule() {
_instance += 1;
return import(MODULE_URL + '?case=' + _instance);
}
function loaderSnapshot(loader) {
return {
revealed: loader.dataset.shellRevealed === 'true',
opacity: loader.style.opacity ?? null,
pointerEvents: loader.style.pointerEvents ?? null,
ariaHidden: loader.getAttribute('aria-hidden') ?? null,
removed: loader.removed,
};
}
const cases = {};
cases.reveal_waits_one_paint_then_keeps_node = async () => {
const w = makeWorld();
const loader = w.addElement('app-loader');
const shell = await loadModule();
shell.revealApplicationShellAfterPaint();
const beforePaint = loaderSnapshot(loader);
w.paint(1);
const afterOneFrame = loaderSnapshot(loader);
w.paint(1);
return {
beforePaint,
afterOneFrame,
afterPaint: loaderSnapshot(loader),
waveStops: w.waveStops,
stillInDocument: w.byId.has('app-loader'),
};
};
cases.reveal_is_idempotent = async () => {
const w = makeWorld();
const loader = w.addElement('app-loader');
const shell = await loadModule();
shell.revealApplicationShellAfterPaint();
shell.revealApplicationShellAfterPaint();
w.paint(2);
shell.revealApplicationShellAfterPaint();
w.paint(2);
return { waveStops: w.waveStops, snapshot: loaderSnapshot(loader) };
};
cases.remove_retires_the_loader_node = async () => {
const w = makeWorld();
const loader = w.addElement('app-loader');
const shell = await loadModule();
shell.removeApplicationLoader();
const beforeTimers = loaderSnapshot(loader);
w.runTimers();
return { beforeTimers, afterTimers: loaderSnapshot(loader) };
};
cases.failed_hydration_marks_sidebar_row = async () => {
const w = makeWorld();
w.addElement('app-loader');
const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
const shell = await loadModule();
await shell.settleSessionHydration(() => Promise.reject(new Error('boom')));
const beforePaint = row.status.textContent;
w.paint(2);
w.runTimers();
return {
beforePaint,
afterPaint: row.status.textContent,
loaderRemoved: !w.byId.has('app-loader'),
};
};
// A successful load with zero sessions must not schedule a failure write.
cases.zero_session_success_shows_no_failure = async () => {
const w = makeWorld();
w.addElement('app-loader');
const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
const shell = await loadModule();
await shell.settleSessionHydration(() => Promise.resolve(true));
w.paint(1);
row.remove(); // renderSessionList() clearing #session-list
w.paint(1);
return { statusText: row.status.textContent, rowRemoved: row.removed };
};
// The whole point is getting /api/sessions off the critical path, not later.
cases.hydration_starts_synchronously = async () => {
const w = makeWorld();
w.addElement('app-loader');
const shell = await loadModule();
let started = false;
const done = shell.settleSessionHydration(() => { started = true; return Promise.resolve(true); });
const startedBeforeAwait = started;
await done;
return { startedBeforeAwait };
};
cases.synchronous_load_failure_still_settles = async () => {
const w = makeWorld();
w.addElement('app-loader');
const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
const shell = await loadModule();
let opened = 0;
shell.deferRouteOpener('/email', () => { opened += 1; });
let threw = false;
let succeeded = true;
try {
succeeded = await shell.settleSessionHydration(() => { throw new Error('module blew up'); });
} catch (_) { threw = true; }
w.paint(2);
w.runTimers();
return {
threw,
succeeded,
opened,
statusText: row.status.textContent,
loaderRemoved: !w.byId.has('app-loader'),
ranAfterFailure: shell.runDeferredRouteOpener({ sessionsSettled: true }),
};
};
cases.route_without_session_data_opens_before_hydration = async () => {
const w = makeWorld();
w.addElement('app-loader');
const shell = await loadModule();
let opened = 0;
shell.deferRouteOpener('/notes', () => { opened += 1; });
const ranEarly = shell.runDeferredRouteOpener();
const openedAfterEarly = opened;
const ranAgain = shell.runDeferredRouteOpener({ sessionsSettled: true });
return { ranEarly, openedAfterEarly, ranAgain, opened };
};
cases.route_with_session_data_waits_for_hydration = async () => {
const w = makeWorld();
w.addElement('app-loader');
const shell = await loadModule();
let opened = 0;
shell.deferRouteOpener('/email', () => { opened += 1; });
const ranEarly = shell.runDeferredRouteOpener();
const openedAfterEarly = opened;
const succeeded = await shell.settleSessionHydration(() => Promise.resolve(true));
return {
ranEarly,
openedAfterEarly,
openedAfterHydration: opened,
succeeded,
needsSessions: [shell.routeNeedsSessionData('/email'), shell.routeNeedsSessionData('/notes')],
};
};
cases.missing_session_module_keeps_route_deferred = async () => {
const w = makeWorld();
w.addElement('app-loader');
const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
const shell = await loadModule();
let opened = 0;
shell.deferRouteOpener('/email', () => { opened += 1; });
const succeeded = await shell.settleSessionHydration(null);
w.paint(2);
w.runTimers();
return {
opened,
succeeded,
statusText: row.status.textContent,
loaderRemoved: !w.byId.has('app-loader'),
ranAfterFailure: shell.runDeferredRouteOpener({ sessionsSettled: true }),
};
};
cases.throwing_route_opener_is_contained = async () => {
const w = makeWorld();
w.addElement('app-loader');
const shell = await loadModule();
shell.deferRouteOpener('/notes', () => { throw new Error('opener blew up'); });
let threw = false;
let ran = false;
try { ran = shell.runDeferredRouteOpener(); } catch (_) { threw = true; }
return { threw, ran, ranAgain: shell.runDeferredRouteOpener({ sessionsSettled: true }) };
};
const out = {};
for (const [name, fn] of Object.entries(cases)) out[name] = await fn();
console.log(JSON.stringify(out));
""".replace("MODULE_PATH", _MODULE_URL)
@pytest.fixture(scope="module")
def results():
if not _HAS_NODE:
pytest.skip("node is not installed")
proc = subprocess.run(
["node", "--input-type=module", "-e", _HARNESS],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, f"node harness failed:\n{proc.stderr}"
return json.loads(proc.stdout.strip().splitlines()[-1])
def test_module_exists():
assert _MODULE.is_file(), f"missing {_MODULE}"
def test_shell_is_revealed_one_paint_after_wiring(results):
r = results["reveal_waits_one_paint_then_keeps_node"]
assert r["beforePaint"]["revealed"] is False, "revealed before any frame ran"
assert r["afterOneFrame"]["revealed"] is False, "revealed before the paint committed"
assert r["afterPaint"] == {
"revealed": True,
"opacity": "0",
"pointerEvents": "none",
"ariaHidden": "true",
"removed": False,
}
assert r["waveStops"] == 1, "loader wave interval kept running after reveal"
def test_revealed_loader_stays_as_startup_sentinel(results):
# sessions.js / sidebar-layout.js read #app-loader as "startup in progress".
r = results["reveal_waits_one_paint_then_keeps_node"]
assert r["stillInDocument"] is True
assert r["afterPaint"]["removed"] is False
def test_reveal_is_idempotent(results):
r = results["reveal_is_idempotent"]
assert r["waveStops"] == 1, "reveal ran its side effects more than once"
assert r["snapshot"]["revealed"] is True
def test_loader_node_is_retired_after_the_fade(results):
r = results["remove_retires_the_loader_node"]
assert r["beforeTimers"]["revealed"] is True, "removal should hide immediately"
assert r["beforeTimers"]["removed"] is False, "removal should wait for the fade"
assert r["afterTimers"]["removed"] is True, "loader node outlived hydration"
def test_failed_session_load_marks_the_sidebar_row(results):
r = results["failed_hydration_marks_sidebar_row"]
assert r["beforePaint"] == "Loading chats…", "failure written before the render frame"
assert r["afterPaint"] == "Chats unavailable"
assert r["loaderRemoved"] is True, "a failed load must still free the shell"
def test_zero_session_success_never_shows_a_failure(results):
r = results["zero_session_success_shows_no_failure"]
assert r["rowRemoved"] is True
assert r["statusText"] == "Loading chats…", "false 'Chats unavailable' on empty success"
def test_hydration_request_starts_synchronously(results):
r = results["hydration_starts_synchronously"]
assert r["startedBeforeAwait"] is True, "/api/sessions start was deferred a microtask"
def test_synchronous_load_failure_still_settles(results):
r = results["synchronous_load_failure_still_settles"]
assert r["threw"] is False, "a throwing loadSessions must not escape"
assert r["succeeded"] is False
assert r["opened"] == 0, "session-dependent route opened without session data"
assert r["ranAfterFailure"] is False, "failed startup left a stale route opener"
assert r["statusText"] == "Chats unavailable"
assert r["loaderRemoved"] is True
def test_route_needing_no_session_data_opens_before_hydration(results):
r = results["route_without_session_data_opens_before_hydration"]
assert r["ranEarly"] is True, "/notes waited on /api/sessions it does not read"
assert r["openedAfterEarly"] == 1
assert r["ranAgain"] is False, "route opener fired twice"
assert r["opened"] == 1
def test_route_needing_session_data_waits_for_hydration(results):
r = results["route_with_session_data_waits_for_hydration"]
assert r["ranEarly"] is False, "/email opened before the session list was there"
assert r["openedAfterEarly"] == 0
assert r["openedAfterHydration"] == 1
assert r["succeeded"] is True
assert r["needsSessions"] == [True, False]
def test_missing_session_module_still_settles_without_opening_data_route(results):
r = results["missing_session_module_keeps_route_deferred"]
assert r["succeeded"] is False
assert r["opened"] == 0, "route opened without the session module it depends on"
assert r["ranAfterFailure"] is False, "missing module left a stale route opener"
assert r["statusText"] == "Chats unavailable"
assert r["loaderRemoved"] is True
def test_throwing_route_opener_is_contained(results):
r = results["throwing_route_opener_is_contained"]
assert r["threw"] is False
assert r["ran"] is True
assert r["ranAgain"] is False, "a failed opener must not be retried"
+9 -1
View File
@@ -21,10 +21,17 @@ def test_call_teacher_scopes_model_resolution_to_owner(monkeypatch):
return ("http://endpoint.local/v1", "teacher-model", {})
async def fake_llm_call_async(url, model, messages, **kwargs):
seen["messages"] = messages
return "teacher reply"
from src.agent_tools import model_interaction_tools
monkeypatch.setattr("src.ai_interaction._resolve_model", fake_resolve_model)
monkeypatch.setattr("src.ai_interaction._TEACHER_SYSTEM_PROMPT", "sys", raising=False)
monkeypatch.setattr(
model_interaction_tools,
"_TEACHER_SYSTEM_PROMPT",
"sys",
)
monkeypatch.setattr("src.llm_core.llm_call_async", fake_llm_call_async)
result = asyncio.run(
@@ -34,6 +41,7 @@ def test_call_teacher_scopes_model_resolution_to_owner(monkeypatch):
assert result == "teacher reply"
assert seen["owner"] == "alice"
assert seen["spec"] == "teacher-model"
assert seen["messages"][0] == {"role": "system", "content": "sys"}
def test_audit_teacher_resolution_scoped_to_owner(monkeypatch):
@@ -0,0 +1,99 @@
"""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"),
("x assistant y", "x assistant y"), # mid-sentence must survive (#5971)
]
# 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"),
("Before\nassistant\nAfter", "Before \nAfter"), # bare-marker on its own line still stripped
("Before\n assistant\t \nAfter", "Before \nAfter"), # whitespace-padded marker still stripped
("Before\n\tassistan \nAfter", "Before \nAfter"), # truncated marker variant still stripped
]
@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}"
+94
View File
@@ -0,0 +1,94 @@
"""Qwen/Hermes text-mode tool calls: bare JSON inside <tool_call> wrappers.
Issue #5187: <tool_call>{"name": "bash", "arguments": {...}}</tool_call>
parsed to zero blocks because wrapper bodies were only fed to the XML
iterators. The JSON body form now parses through the same canonical
function_call_to_tool_block converter as the XML paths, and JSON-looking
bodies fail closed instead of falling through to XML scanning (tracker #5333):
XML-like text inside JSON argument values must stay data, and a non-object
"arguments" value is rejected rather than coerced.
"""
import src.agent_tools # noqa: F401 (break agent_tools<->tool_parsing import cycle)
from src.tool_parsing import parse_tool_blocks, strip_tool_blocks
# Verbatim payload from issue #5187.
ISSUE_PAYLOAD = '<tool_call>\n{"name": "bash", "arguments": {"command": "mkdir -p agent-test"}}\n</tool_call>'
def test_issue_5187_payload_parses():
blocks = parse_tool_blocks(ISSUE_PAYLOAD)
assert len(blocks) == 1
assert blocks[0].tool_type == "bash"
assert blocks[0].content == "mkdir -p agent-test"
def test_multiple_sequential_wrappers():
text = (
'<tool_call>\n{"name": "bash", "arguments": {"command": "ls"}}\n</tool_call>\n'
'Now the second step:\n'
'<tool_call>\n{"name": "bash", "arguments": {"command": "pwd"}}\n</tool_call>'
)
blocks = parse_tool_blocks(text)
assert [(b.tool_type, b.content) for b in blocks] == [("bash", "ls"), ("bash", "pwd")]
def test_unclosed_wrapper_still_parses():
text = '<tool_call>\n{"name": "bash", "arguments": {"command": "ls -la"}}'
blocks = parse_tool_blocks(text)
assert len(blocks) == 1
assert blocks[0].tool_type == "bash"
assert blocks[0].content == "ls -la"
def test_xml_inside_json_arguments_stays_data():
# P1: a valid JSON body whose argument values contain XML-like tool markup
# must parse as the JSON-named tool; the embedded markup is content.
text = (
'<tool_call>{"name": "write_file", "arguments": '
'{"path": "notes.txt", "content": "<bash>echo unsafe</bash>"}}</tool_call>'
)
blocks = parse_tool_blocks(text)
assert len(blocks) == 1
assert blocks[0].tool_type == "write_file"
assert "<bash>echo unsafe</bash>" in blocks[0].content
assert all(b.tool_type != "bash" for b in blocks)
def test_malformed_json_body_never_falls_through_to_xml():
# P1 fail-closed: a JSON-looking body that doesn't decode must not be
# rescanned as XML, even when it contains well-formed tool markup.
text = (
'<tool_call>{"name": "write_file", "arguments": {broken json '
'<invoke name="bash"><parameter name="command">echo unsafe</parameter></invoke>'
'</tool_call>'
)
assert parse_tool_blocks(text) == []
def test_non_dict_arguments_rejected():
# P2: "arguments" must be an object; scalars/arrays are rejected, not coerced.
for args in ('["ls"]', '"ls"', '1', 'null'):
text = '<tool_call>{"name": "bash", "arguments": %s}</tool_call>' % args
assert parse_tool_blocks(text) == [], f"arguments={args} should be rejected"
def test_strip_tool_blocks_removes_json_wrapper_spans():
text = "Before.\n" + ISSUE_PAYLOAD + "\nAfter."
cleaned = strip_tool_blocks(text)
assert "tool_call" not in cleaned
assert "mkdir -p agent-test" not in cleaned
assert "Before." in cleaned
assert "After." in cleaned
def test_xml_body_wrapper_regression():
# The pre-existing XML wrapper form must keep parsing exactly as before.
text = (
'<tool_call><invoke name="bash">'
'<parameter name="command">echo hi</parameter>'
'</invoke></tool_call>'
)
blocks = parse_tool_blocks(text)
assert len(blocks) == 1
assert blocks[0].tool_type == "bash"
assert blocks[0].content == "echo hi"
@@ -0,0 +1,97 @@
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()
+139
View File
@@ -0,0 +1,139 @@
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH")
def _node_eval(source):
result = subprocess.run(
["node", "--input-type=module", "-e", source],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return json.loads(result.stdout)
def _resolve(state):
"""Run resolveVisibility(state) in node; return {selector: visible}."""
return _node_eval(
f"""
const {{ resolveVisibility }} = await import('./static/js/ui_visibility.js');
console.log(JSON.stringify(resolveVisibility({json.dumps(state)})));
"""
)
def _map():
return _node_eval(
"""
const { UI_VIS_MAP } = await import('./static/js/ui_visibility.js');
console.log(JSON.stringify(UI_VIS_MAP));
"""
)
# Selectors (kept in one place so the tests read as plain assertions).
EMAIL = "#email-section, #rail-email"
TOOLS = "#tools-section"
CAL = "#tool-calendar-btn, #rail-calendar"
COMPARE = "#tool-compare-btn, #rail-compare"
LIB = "#tool-library-btn, #rail-archive"
RESEARCH = "#tool-research-btn, #rail-research"
NEWCHAT = "#rail-new-session"
RAG = "#overflow-rag-btn"
# Full-sidebar tabs that have an icon-rail counterpart must pair it into their
# UI_VIS_MAP selector; otherwise minimizing the sidebar re-shows a tab the user
# turned off in the full view (#tool-library-btn's rail counterpart is #rail-archive).
EXPECTED_RAIL_PAIRS = {
"email-section": "#rail-email",
"tool-calendar": "#rail-calendar",
"tool-compare": "#rail-compare",
"tool-cookbook": "#rail-cookbook",
"tool-research": "#rail-research",
"tool-gallery": "#rail-gallery",
"tool-library": "#rail-archive",
"tool-memory": "#rail-memory",
"tool-notes": "#rail-notes",
"tool-tasks": "#rail-tasks",
"tool-theme": "#rail-theme",
}
def test_every_customizable_tab_pairs_its_rail_button():
ui_vis_map = _map()
missing = {
key: rail
for key, rail in EXPECTED_RAIL_PAIRS.items()
if rail not in ui_vis_map.get(key, "")
}
assert not missing, (
"these tabs are missing their icon-rail counterpart in UI_VIS_MAP "
f"(minimizing the sidebar would re-show them): {missing}"
)
def test_defaults_everything_visible_except_default_off():
m = _resolve({})
assert m[EMAIL] is True
assert m[TOOLS] is True
assert m[CAL] is True
assert m[NEWCHAT] is True
assert m[RAG] is False # rag-toggle-btn is default-off
def test_email_off_hides_email_and_its_rail_only():
m = _resolve({"email-section": False})
assert m[EMAIL] is False
assert m[CAL] is True
assert m[TOOLS] is True
def test_tool_off_hides_its_rail_launcher():
m = _resolve({"tool-calendar": False})
assert m[CAL] is False
assert m[COMPARE] is True
def test_library_off_hides_archive_rail():
# tool-library's rail counterpart is #rail-archive (mirrors _railToolMap).
m = _resolve({"tool-library": False})
assert m[LIB] is False
def test_tools_off_hides_every_tool_rail_but_not_email():
m = _resolve({"tools-section": False})
assert m[TOOLS] is False
for sel in (CAL, COMPARE, LIB, RESEARCH):
assert m[sel] is False, sel
assert m[EMAIL] is True # email is independent of the Tools section
def test_tools_off_overrides_per_tool_on():
# A tool individually "on" must still hide when its parent Tools is off.
m = _resolve({"tools-section": False, "tool-calendar": True})
assert m[CAL] is False
def test_tools_on_with_tool_off_hides_only_that_tool():
m = _resolve({"tools-section": True, "tool-research": False})
assert m[RESEARCH] is False
assert m[CAL] is True
def test_rail_new_chat_off_hides_new_session():
m = _resolve({"rail-new-chat": False})
assert m[NEWCHAT] is False
def test_explicit_false_takes_precedence_over_default_on():
m = _resolve({"rag-toggle-btn": True})
assert m[RAG] is True
+77 -8
View File
@@ -15,6 +15,7 @@ These tests exercise:
* Smoke tests: normal upload, duplicate detection, info lookup after
a backup-recovery scenario.
"""
import builtins
import concurrent.futures
import io
import json
@@ -59,6 +60,16 @@ def _db_path(handler: UploadHandler) -> str:
return os.path.join(handler.upload_dir, "uploads.json")
def _truncate_without_newer_mtime(path: str) -> None:
"""Model a filesystem where a torn write shares the cached timestamp."""
before = os.stat(path)
with open(path, "rb") as f:
full = f.read()
with open(path, "wb") as f:
f.write(full[: max(1, len(full) // 2)])
os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns))
def _seed_entry(owner: str, file_hash: str, file_id: str) -> dict:
return {
"id": file_id,
@@ -246,10 +257,7 @@ def test_partial_write_recovery_via_bak(tmp_path):
"Production _atomic_write_json must create a .bak sibling on subsequent writes."
)
full = open(db_path, "rb").read()
truncated_len = max(1, len(full) // 2)
with open(db_path, "wb") as f:
f.write(full[:truncated_len])
_truncate_without_newer_mtime(db_path)
recovered = handler._load_upload_index()
missing = [k for k in original if k not in recovered]
@@ -259,6 +267,69 @@ def test_partial_write_recovery_via_bak(tmp_path):
)
def test_partial_write_recovery_via_bak_after_restart(tmp_path):
"""A fresh handler must recover the previous snapshot from ``.bak``."""
handler = _make_handler(tmp_path)
db_path = _db_path(handler)
original = {
f"owner:hash_{i}": _seed_entry("owner", f"hash_{i}", f"id_{i}")
for i in range(3)
}
handler._atomic_write_json(db_path, original)
handler._atomic_write_json(db_path, {"latest": True})
_truncate_without_newer_mtime(db_path)
restarted_handler = UploadHandler(
base_dir=handler.base_dir,
upload_dir=handler.upload_dir,
)
assert restarted_handler._load_upload_index() == original
def test_unchanged_upload_index_uses_cache(tmp_path, monkeypatch):
"""The stronger file signature must preserve the unchanged-index fast path."""
handler = _make_handler(tmp_path)
original = {"owner:hash": _seed_entry("owner", "hash", "id")}
handler._atomic_write_json(_db_path(handler), original)
def fail_if_parsed(_file):
raise AssertionError("unchanged upload index should be served from cache")
monkeypatch.setattr(json, "load", fail_if_parsed)
assert handler._load_upload_index() == original
def test_upload_index_retries_when_replaced_during_read(tmp_path, monkeypatch):
"""Do not cache old JSON under the signature of a newer atomic replace."""
handler = _make_handler(tmp_path)
db_path = _db_path(handler)
old_index = {"owner:old": _seed_entry("owner", "old", "old_id")}
new_index = {"owner:new": _seed_entry("owner", "new", "new_id")}
handler._atomic_write_json(db_path, old_index)
handler._index_cache = None
handler._index_signature = None
real_open = builtins.open
replaced = False
def racing_open(file, mode="r", *args, **kwargs):
nonlocal replaced
handle = real_open(file, mode, *args, **kwargs)
if os.fspath(file) == db_path and "r" in mode and not replaced:
replaced = True
replacement = db_path + ".replacement"
with real_open(replacement, "w", encoding="utf-8") as out:
json.dump(new_index, out)
os.replace(replacement, db_path)
return handle
monkeypatch.setattr(builtins, "open", racing_open)
assert handler._load_upload_index() == new_index
# ---------------------------------------------------------------------------
# Atomicity primitive audit on the production module.
# ---------------------------------------------------------------------------
@@ -390,10 +461,8 @@ def test_smoke_info_lookup_after_bak_recovery(tmp_path):
handler._atomic_write_json(db_path, {"sentinel": True})
assert os.path.exists(db_path + ".bak")
# Truncate the live file.
full = open(db_path, "rb").read()
with open(db_path, "wb") as f:
f.write(full[: max(1, len(full) // 2)])
# Truncate the live file without assuming the filesystem advances mtime.
_truncate_without_newer_mtime(db_path)
info = handler.get_upload_info(first["id"])
assert info is not None, "Info lookup must succeed after .bak recovery."
+11
View File
@@ -0,0 +1,11 @@
"""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
+1 -1
View File
@@ -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_routes.py").read_text()
document_source = (ROOT / "routes" / "document" / "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()
+11
View File
@@ -0,0 +1,11 @@
"""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