mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-28 11:02:21 +02:00
Merge branch 'dev' into fix/add-directory-event-loop
This commit is contained in:
@@ -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('<think>literal</think>'),
|
||||
'<think>literal</think>',
|
||||
);
|
||||
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, ['']);
|
||||
});
|
||||
@@ -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"}}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
@@ -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
@@ -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
|
||||
@@ -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}
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 `$&`.
|
||||
html = _run_markdown_case(
|
||||
"```sh\necho \"hello world\" | perl -pe 's/world/$& again/'\n```"
|
||||
)
|
||||
|
||||
assert "___CODE_BLOCK_" not in html
|
||||
assert "s/world/$& again/" in html
|
||||
assert "amp; again" not in html.replace("$& again", "")
|
||||
|
||||
|
||||
def test_fenced_code_keeps_dollar_backtick_and_quote(node_available):
|
||||
# `` $` `` and `$'` splice the text before/after the placeholder into the
|
||||
# block. Unlike `$&` these leave no placeholder behind — the characters just
|
||||
# vanish — so assert the content survives verbatim.
|
||||
html = _run_markdown_case("```sh\nsed \"s/$`/x/\" && sed \"s/$'/y/\"\n```")
|
||||
|
||||
assert "___CODE_BLOCK_" not in html
|
||||
assert "s/$`/x/" in html
|
||||
assert "s/$'/y/" in html
|
||||
|
||||
|
||||
def test_fenced_code_keeps_double_dollar(node_available):
|
||||
# `$$` collapsed to a single `$` in the restored block.
|
||||
html = _run_markdown_case('```sh\necho "$$USD and $$"\n```')
|
||||
|
||||
assert "$$USD and $$" in html
|
||||
|
||||
|
||||
def test_mermaid_block_keeps_dollar_ampersand(node_available):
|
||||
# The mermaid restore site had the same hazard: a node label containing `$&`
|
||||
# re-inserted the ___MERMAID_BLOCK_n___ placeholder into the diagram source,
|
||||
# which then fails to parse. The math and allowed-HTML sites are fixed the
|
||||
# same way; they need KaTeX/sanitizer conditions this harness doesn't set up.
|
||||
html = _run_markdown_case('```mermaid\ngraph TD; A["$&"] --> B;\n```')
|
||||
|
||||
assert "___MERMAID_BLOCK_" not in html
|
||||
assert "$&" in html
|
||||
|
||||
|
||||
def test_currency_dollar_amounts_are_not_rendered_as_math(node_available):
|
||||
# "$5 to $10" used to pair the two dollar signs as inline-math delimiters
|
||||
# and render "5 to" through KaTeX. Pandoc-style rules now reject it: the
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user