mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-09 23:18:50 -04:00
Merge verified Odysseus fixes
This commit is contained in:
@@ -56,6 +56,20 @@ def test_explicit_web_search_promotes_to_agent():
|
||||
assert classify_tool_intent("use web search and find a recipe").category == "web"
|
||||
|
||||
|
||||
def test_workspace_agent_requests_promote_to_shell_workspace():
|
||||
prompts = [
|
||||
"fix the bug in this repo",
|
||||
"run the tests for this project",
|
||||
"debug the server logs",
|
||||
"run terminal-bench on this task",
|
||||
"inspect the traceback and patch the code",
|
||||
]
|
||||
for prompt in prompts:
|
||||
intent = classify_tool_intent(prompt)
|
||||
assert intent.needs_tools
|
||||
assert intent.category == "workspace"
|
||||
|
||||
|
||||
def test_explanatory_calendar_questions_stay_plain_chat():
|
||||
assert not message_needs_tools("How do I add an entry to my calendar?")
|
||||
assert not message_needs_tools("What about the built-in Odysseus calendar, is that linked to email?")
|
||||
|
||||
@@ -36,6 +36,66 @@ def test_npx_package_from_args_prefers_package_after_y_flag(monkeypatch):
|
||||
) == "@playwright/mcp@latest"
|
||||
|
||||
|
||||
def test_browser_mcp_cache_requirement_is_opt_in(monkeypatch):
|
||||
monkeypatch.delenv("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", raising=False)
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
assert builtin_mcp.BROWSER_MCP_REQUIRE_CACHE is False
|
||||
|
||||
|
||||
def test_browser_mcp_cache_requirement_can_be_enabled(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", "1")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
assert builtin_mcp.BROWSER_MCP_REQUIRE_CACHE is True
|
||||
|
||||
|
||||
def test_browser_mcp_args_use_configured_browser_executable(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
|
||||
|
||||
assert "--executable-path" in args
|
||||
assert "/usr/bin/chromium" in args
|
||||
assert "--isolated" in args
|
||||
assert "--no-sandbox" in args
|
||||
|
||||
|
||||
def test_browser_mcp_args_can_use_persistent_profile_when_requested(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_ISOLATED", "0")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
|
||||
|
||||
assert "--executable-path" in args
|
||||
assert "--isolated" not in args
|
||||
|
||||
|
||||
def test_browser_mcp_args_respect_explicit_user_data_dir(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
args = builtin_mcp._browser_mcp_args([
|
||||
"-y", "@playwright/mcp@latest", "--headless", "--user-data-dir", "/tmp/profile",
|
||||
])
|
||||
|
||||
assert "--user-data-dir" in args
|
||||
assert "--isolated" not in args
|
||||
|
||||
|
||||
def test_browser_mcp_args_can_keep_sandbox(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_NO_SANDBOX", "0")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
|
||||
|
||||
assert "--executable-path" in args
|
||||
assert "--no-sandbox" not in args
|
||||
|
||||
|
||||
def test_npx_cache_check_detects_scoped_package_in_npx_cache(monkeypatch, tmp_path):
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
package_json = (
|
||||
|
||||
@@ -80,7 +80,7 @@ async def test_consolidate_memory_empty_owner_treats_each_owner_separately(monke
|
||||
message, ok = await action_consolidate_memory("")
|
||||
|
||||
assert ok is True
|
||||
assert "removed 1" in message
|
||||
assert "removed 1" in message.lower()
|
||||
assert len(prompts) == 2
|
||||
saved = {m["id"]: m for m in _read_memories(data_dir)}
|
||||
assert set(saved) == {"alice-long", "alice-short", "bob-keep"}
|
||||
@@ -114,3 +114,46 @@ async def test_consolidate_memory_specific_owner_does_not_absorb_ownerless_rows(
|
||||
assert set(saved) == {"alice-1", "legacy", "bob-1"}
|
||||
assert "owner" not in saved["legacy"]
|
||||
assert saved["bob-1"]["owner"] == "bob"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidate_memory_removes_near_duplicates_before_ai(monkeypatch, tmp_path):
|
||||
from src import constants
|
||||
from src import llm_core
|
||||
from src import task_endpoint
|
||||
action_consolidate_memory = _import_consolidate_action()
|
||||
|
||||
data_dir = _write_memories(
|
||||
tmp_path,
|
||||
[
|
||||
{"id": "a", "owner": "alice", "text": "User prefers bullet points when explaining.", "category": "preference"},
|
||||
{"id": "b", "owner": "alice", "text": "User prefers bulletpoints when explaining", "category": "preference", "pinned": True},
|
||||
{"id": "c", "owner": "alice", "text": "User likes local models.", "category": "preference"},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(constants, "DATA_DIR", str(data_dir))
|
||||
monkeypatch.setattr(
|
||||
task_endpoint,
|
||||
"resolve_task_candidates",
|
||||
lambda *args, **kwargs: [("http://llm", "model", {})],
|
||||
)
|
||||
|
||||
async def fake_llm_call_async(_candidates, **kwargs):
|
||||
items = json.loads(kwargs["messages"][0]["content"].split("MEMORIES:\n", 1)[1])
|
||||
return json.dumps({
|
||||
"keep": [
|
||||
{"id": item["id"], "text": item["text"], "category": item["category"]}
|
||||
for item in items
|
||||
],
|
||||
"drop": [],
|
||||
})
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async_with_fallback", fake_llm_call_async)
|
||||
|
||||
message, ok = await action_consolidate_memory("alice")
|
||||
|
||||
assert ok is True
|
||||
assert "removed 1" in message.lower()
|
||||
saved = {m["id"]: m for m in _read_memories(data_dir)}
|
||||
assert set(saved) == {"b", "c"}
|
||||
assert saved["b"]["pinned"] is True
|
||||
|
||||
@@ -342,10 +342,10 @@ def test_clean_thinking_for_save_extracts_thought_tag():
|
||||
assert metadata["thinking"] == "internal reasoning"
|
||||
|
||||
|
||||
def test_save_assistant_response_preserves_actual_and_requested_model():
|
||||
def test_save_assistant_response_incognito_does_not_mutate_session_history():
|
||||
sess = _FakeSession("selected-model")
|
||||
|
||||
save_assistant_response(
|
||||
saved_id = save_assistant_response(
|
||||
sess,
|
||||
session_manager=None,
|
||||
session_id="s1",
|
||||
@@ -354,8 +354,24 @@ def test_save_assistant_response_preserves_actual_and_requested_model():
|
||||
incognito=True,
|
||||
)
|
||||
|
||||
assert sess.history[-1].metadata["requested_model"] == "selected-model"
|
||||
assert sess.history[-1].metadata["model"] == "actual-model"
|
||||
assert saved_id is None
|
||||
assert sess.history == []
|
||||
|
||||
|
||||
def test_add_user_message_incognito_does_not_mutate_session_history():
|
||||
sess = _FakeSession("selected-model")
|
||||
chat_handler = SimpleNamespace(update_session_name_if_needed=lambda *_args, **_kwargs: None)
|
||||
preprocessed = PreprocessedMessage(
|
||||
enhanced_message="secret",
|
||||
user_content="secret",
|
||||
text_for_context="secret",
|
||||
youtube_transcripts=[],
|
||||
attachment_meta=[],
|
||||
)
|
||||
|
||||
chat_helpers.add_user_message(sess, chat_handler, preprocessed, incognito=True)
|
||||
|
||||
assert sess.history == []
|
||||
|
||||
|
||||
class _SpinMsg:
|
||||
|
||||
@@ -60,6 +60,15 @@ def test_image_model_prefix_routes_to_image_generation_without_endpoint_lookup(m
|
||||
assert chat_routes._is_image_generation_session(_session(model="dall-e-3"))
|
||||
|
||||
|
||||
def test_namespaced_gpt_image_model_routes_to_image_generation_without_endpoint_lookup(monkeypatch):
|
||||
def fail_if_called():
|
||||
raise AssertionError("provider-prefixed image models should not need a DB lookup")
|
||||
|
||||
monkeypatch.setattr(chat_routes, "SessionLocal", fail_if_called)
|
||||
|
||||
assert chat_routes._is_image_generation_session(_session(model="openai/gpt-5-image"))
|
||||
|
||||
|
||||
def test_image_endpoint_does_not_catch_text_model_on_different_path(monkeypatch):
|
||||
db = _FakeDb([
|
||||
_endpoint("http://localhost:11434/v1/images", models=["sdxl-local"]),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.chat_processor import ChatProcessor
|
||||
|
||||
|
||||
class _Memory:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
self.incremented = []
|
||||
|
||||
def load(self, owner=None):
|
||||
return list(self.rows)
|
||||
|
||||
def increment_uses(self, ids):
|
||||
self.incremented.extend(ids)
|
||||
|
||||
|
||||
class _Docs:
|
||||
rag_manager = None
|
||||
|
||||
|
||||
def _context_text(preface):
|
||||
return "\n".join(m.get("content", "") for m in preface)
|
||||
|
||||
|
||||
def _processor(rows):
|
||||
return ChatProcessor(memory_manager=_Memory(rows), personal_docs_manager=_Docs())
|
||||
|
||||
|
||||
def test_pinned_memory_does_not_inject_every_unrelated_fact():
|
||||
rows = [
|
||||
{
|
||||
"id": "identity",
|
||||
"text": "User's name is Felix.",
|
||||
"category": "identity",
|
||||
"pinned": True,
|
||||
"timestamp": 3,
|
||||
},
|
||||
{
|
||||
"id": "party",
|
||||
"text": "User is planning a birthday party with sack races.",
|
||||
"category": "fact",
|
||||
"pinned": True,
|
||||
"timestamp": 2,
|
||||
},
|
||||
{
|
||||
"id": "coffee",
|
||||
"text": "User likes dark roast coffee.",
|
||||
"category": "preference",
|
||||
"pinned": True,
|
||||
"timestamp": 1,
|
||||
},
|
||||
]
|
||||
|
||||
preface, _, _ = _processor(rows).build_context_preface(
|
||||
message="Explain how Python decorators work",
|
||||
session=SimpleNamespace(),
|
||||
use_rag=False,
|
||||
use_memory=True,
|
||||
)
|
||||
|
||||
text = _context_text(preface)
|
||||
assert "User's name is Felix." in text
|
||||
assert "birthday party with sack races" not in text
|
||||
assert "dark roast coffee" not in text
|
||||
|
||||
|
||||
def test_relevant_pinned_memory_is_still_injected():
|
||||
rows = [
|
||||
{
|
||||
"id": "coffee",
|
||||
"text": "User likes dark roast coffee.",
|
||||
"category": "preference",
|
||||
"pinned": True,
|
||||
"timestamp": 1,
|
||||
},
|
||||
{
|
||||
"id": "party",
|
||||
"text": "User is planning a birthday party with sack races.",
|
||||
"category": "fact",
|
||||
"pinned": True,
|
||||
"timestamp": 2,
|
||||
},
|
||||
]
|
||||
|
||||
preface, _, _ = _processor(rows).build_context_preface(
|
||||
message="likes coffee roast",
|
||||
session=SimpleNamespace(),
|
||||
use_rag=False,
|
||||
use_memory=True,
|
||||
)
|
||||
|
||||
text = _context_text(preface)
|
||||
assert "User likes dark roast coffee." in text
|
||||
assert "birthday party with sack races" not in text
|
||||
|
||||
|
||||
def test_pinned_memory_injection_is_capped_at_five():
|
||||
rows = [
|
||||
{
|
||||
"id": f"identity-{idx}",
|
||||
"text": f"User identity fact {idx} email marker.",
|
||||
"category": "identity",
|
||||
"pinned": True,
|
||||
"timestamp": idx,
|
||||
}
|
||||
for idx in range(10)
|
||||
]
|
||||
|
||||
processor = _processor(rows)
|
||||
processor.build_context_preface(
|
||||
message="Who is the user?",
|
||||
session=SimpleNamespace(),
|
||||
use_rag=False,
|
||||
use_memory=True,
|
||||
)
|
||||
|
||||
assert len(processor._last_used_memories) == 5
|
||||
|
||||
|
||||
def test_total_memory_injection_is_capped_at_five_across_pinned_and_recalled():
|
||||
rows = [
|
||||
{
|
||||
"id": f"identity-{idx}",
|
||||
"text": f"User identity fact {idx} email marker.",
|
||||
"category": "identity",
|
||||
"pinned": True,
|
||||
"timestamp": idx,
|
||||
}
|
||||
for idx in range(4)
|
||||
]
|
||||
rows.extend([
|
||||
{
|
||||
"id": f"coffee-{idx}",
|
||||
"text": f"User likes coffee roast {idx}.",
|
||||
"category": "preference",
|
||||
"pinned": False,
|
||||
"timestamp": idx,
|
||||
}
|
||||
for idx in range(6)
|
||||
])
|
||||
|
||||
processor = _processor(rows)
|
||||
processor.build_context_preface(
|
||||
message="likes coffee roast",
|
||||
session=SimpleNamespace(),
|
||||
use_rag=False,
|
||||
use_memory=True,
|
||||
)
|
||||
|
||||
assert len(processor._last_used_memories) <= 5
|
||||
assert sum(1 for m in processor._last_used_memories if m["type"] == "pinned") == 4
|
||||
@@ -79,6 +79,23 @@ def test_allow_web_search_reads_from_body_as_fallback():
|
||||
)
|
||||
|
||||
|
||||
def test_browser_form_followups_include_approval_and_send_phrases():
|
||||
"""Short approval replies after a form/browser turn must keep browser tools available."""
|
||||
source = _CHAT_ROUTES.read_text(encoding="utf-8")
|
||||
assert "approved" in source
|
||||
assert "proceed" in source
|
||||
assert "send(?:\\s+it)?" in source
|
||||
assert "submit(?:\\s+it)?" in source
|
||||
|
||||
|
||||
def test_agent_loop_expands_browser_mcp_tools_from_connected_server():
|
||||
"""Browser intent must not depend on stale hardcoded Playwright tool names."""
|
||||
source = (Path(__file__).resolve().parent.parent / "src" / "agent_loop.py").read_text(encoding="utf-8")
|
||||
assert "def _expand_browser_mcp_tools" in source
|
||||
assert "server_id\") == \"builtin_browser\"" in source
|
||||
assert "_relevant_tools = _expand_browser_mcp_tools(_relevant_tools, mcp_mgr)" in source
|
||||
|
||||
|
||||
def test_disabled_tools_respects_missing_vs_explicit_toggles():
|
||||
"""Bash still defers to privileges, but web is an explicit per-turn opt-in.
|
||||
"""
|
||||
@@ -102,6 +119,14 @@ def test_disabled_tools_respects_missing_vs_explicit_toggles():
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_auto_escalation_keeps_shell_tools():
|
||||
"""Workspace/shell auto-routing must not use the light typed-tool clamp."""
|
||||
source = _CHAT_ROUTES.read_text(encoding="utf-8")
|
||||
assert '_workspace_agent_intent = _tool_intent.category in {"shell", "workspace"}' in source
|
||||
assert "allow_bash = \"true\"" in source
|
||||
assert "if auto_escalated and not _workspace_agent_intent:" in source
|
||||
|
||||
|
||||
# ── Functional tests of the disabled-tools logic ───────────────
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ Driven through `node --input-type=module` so we exercise the real JS without a
|
||||
full Vitest/Jest setup (same approach as test_reply_recipients_js.py). Skips
|
||||
when `node` is not installed rather than failing.
|
||||
|
||||
Locks in: empty composer recalls last user message; non-empty composer is
|
||||
untouched; multiline caret navigation is not hijacked; Shift/Alt/Ctrl/Meta+ArrowUp
|
||||
are ignored; IME composition does not trigger recall; last message is read from
|
||||
#chat-history (dataset.raw), not session sidebar metadata.
|
||||
Locks in: empty composer recalls user messages from the active conversation,
|
||||
repeated ArrowUp walks older prompts in that same chat; non-empty composer is
|
||||
untouched unless it contains the recalled prompt; multiline caret navigation is
|
||||
not hijacked; Shift/Alt/Ctrl/Meta+ArrowUp are ignored; IME composition does not
|
||||
trigger recall; messages are read from #chat-history (dataset.raw), not session
|
||||
sidebar metadata.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
@@ -36,6 +38,8 @@ function makeComposer(initial = '') {
|
||||
},
|
||||
dispatchKey(opts = {}) {
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
let immediateStopped = false;
|
||||
const e = {
|
||||
key: opts.key ?? 'ArrowUp',
|
||||
shiftKey: !!opts.shiftKey,
|
||||
@@ -44,9 +48,11 @@ function makeComposer(initial = '') {
|
||||
metaKey: !!opts.metaKey,
|
||||
isComposing: !!opts.isComposing,
|
||||
preventDefault() { prevented = true; },
|
||||
stopPropagation() { stopped = true; },
|
||||
stopImmediatePropagation() { immediateStopped = true; },
|
||||
};
|
||||
for (const fn of listeners) fn(e);
|
||||
return prevented;
|
||||
return { prevented, stopped, immediateStopped };
|
||||
},
|
||||
};
|
||||
return composer;
|
||||
@@ -58,17 +64,20 @@ function runCase(body) {
|
||||
composer.selectionStart = body.caret;
|
||||
composer.selectionEnd = body.caretEnd ?? body.caret;
|
||||
}
|
||||
const last = body.last ?? 'previous message';
|
||||
const last = body.history ?? body.last ?? 'previous message';
|
||||
let resized = false;
|
||||
wireArrowUpRecall(composer, () => last, {
|
||||
autoResize: () => { resized = true; },
|
||||
});
|
||||
const prevented = composer.dispatchKey(body.event ?? {});
|
||||
const events = body.events ?? [body.event ?? {}];
|
||||
const handled = events.map(ev => composer.dispatchKey(ev));
|
||||
return {
|
||||
value: composer.value,
|
||||
selectionStart: composer.selectionStart,
|
||||
selectionEnd: composer.selectionEnd,
|
||||
prevented,
|
||||
prevented: handled.map(v => v.prevented),
|
||||
stopped: handled.map(v => v.stopped),
|
||||
immediateStopped: handled.map(v => v.immediateStopped),
|
||||
resized,
|
||||
};
|
||||
}
|
||||
@@ -100,7 +109,24 @@ def test_empty_composer_recalls_last_user_message():
|
||||
assert out["value"] == "hello again"
|
||||
assert out["selectionStart"] == len("hello again")
|
||||
assert out["selectionEnd"] == len("hello again")
|
||||
assert out["prevented"] is True
|
||||
assert out["prevented"] == [True]
|
||||
assert out["stopped"] == [True]
|
||||
assert out["immediateStopped"] == [True]
|
||||
assert out["resized"] is True
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_repeated_arrow_up_cycles_current_chat_prompts_newest_first():
|
||||
out = _run([{
|
||||
"initial": "",
|
||||
"history": ["third prompt", "second prompt", "first prompt"],
|
||||
"events": [{}, {}, {}, {}],
|
||||
}])[0]
|
||||
assert out["value"] == "first prompt"
|
||||
assert out["selectionStart"] == len("first prompt")
|
||||
assert out["prevented"] == [True, True, True, True]
|
||||
assert out["stopped"] == [True, True, True, True]
|
||||
assert out["immediateStopped"] == [True, True, True, True]
|
||||
assert out["resized"] is True
|
||||
|
||||
|
||||
@@ -108,7 +134,7 @@ def test_empty_composer_recalls_last_user_message():
|
||||
def test_non_empty_composer_does_not_recall():
|
||||
out = _run([{"initial": "draft in progress", "last": "ignored"}])[0]
|
||||
assert out["value"] == "draft in progress"
|
||||
assert out["prevented"] is False
|
||||
assert out["prevented"] == [False]
|
||||
assert out["resized"] is False
|
||||
|
||||
|
||||
@@ -116,7 +142,7 @@ def test_non_empty_composer_does_not_recall():
|
||||
def test_whitespace_only_composer_is_not_empty():
|
||||
out = _run([{"initial": " ", "last": "ignored"}])[0]
|
||||
assert out["value"] == " "
|
||||
assert out["prevented"] is False
|
||||
assert out["prevented"] == [False]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
@@ -126,7 +152,7 @@ def test_multiline_caret_navigation_preserved():
|
||||
out = _run([{"initial": text, "caret": len(text), "last": "ignored"}])[0]
|
||||
assert out["value"] == text
|
||||
assert out["selectionStart"] == len(text)
|
||||
assert out["prevented"] is False
|
||||
assert out["prevented"] == [False]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
@@ -139,21 +165,21 @@ def test_modified_arrow_up_ignored():
|
||||
]
|
||||
for out in _run(cases):
|
||||
assert out["value"] == ""
|
||||
assert out["prevented"] is False
|
||||
assert out["prevented"] == [False]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_ime_composition_does_not_trigger_recall():
|
||||
out = _run([{"initial": "", "event": {"isComposing": True}, "last": "ignored"}])[0]
|
||||
assert out["value"] == ""
|
||||
assert out["prevented"] is False
|
||||
assert out["prevented"] == [False]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_no_recall_when_last_message_missing():
|
||||
out = _run([{"initial": "", "last": ""}])[0]
|
||||
assert out["value"] == ""
|
||||
assert out["prevented"] is False
|
||||
assert out["prevented"] == [False]
|
||||
assert out["resized"] is False
|
||||
|
||||
|
||||
@@ -182,7 +208,10 @@ def test_wire_is_idempotent():
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_get_last_user_message_from_chat_history():
|
||||
js = f"""
|
||||
import {{ getLastUserMessageFromChatHistory }} from '{_HELPER_URL}';
|
||||
import {{
|
||||
getLastUserMessageFromChatHistory,
|
||||
getUserMessagesFromChatHistory,
|
||||
}} from '{_HELPER_URL}';
|
||||
|
||||
const chatBox = {{
|
||||
id: 'chat-history',
|
||||
@@ -202,6 +231,7 @@ def test_get_last_user_message_from_chat_history():
|
||||
console.log(JSON.stringify({{
|
||||
fromChat: getLastUserMessageFromChatHistory(doc),
|
||||
fromBox: getLastUserMessageFromChatHistory(chatBox),
|
||||
allFromChat: getUserMessagesFromChatHistory(doc),
|
||||
empty: getLastUserMessageFromChatHistory({{ getElementById: () => null }}),
|
||||
noUsers: getLastUserMessageFromChatHistory({{
|
||||
getElementById: () => ({{ querySelectorAll: () => [] }}),
|
||||
@@ -221,6 +251,7 @@ def test_get_last_user_message_from_chat_history():
|
||||
assert json.loads(proc.stdout.strip()) == {
|
||||
"fromChat": "last raw",
|
||||
"fromBox": "last raw",
|
||||
"allFromChat": ["last raw", "first"],
|
||||
"empty": "",
|
||||
"noUsers": "",
|
||||
}
|
||||
@@ -231,7 +262,7 @@ def test_integration_recalls_from_chat_history_dom():
|
||||
js = f"""
|
||||
import {{
|
||||
wireArrowUpRecall,
|
||||
getLastUserMessageFromChatHistory,
|
||||
getUserMessagesFromChatHistory,
|
||||
}} from '{_HELPER_URL}';
|
||||
|
||||
const chatBox = {{
|
||||
@@ -251,7 +282,7 @@ def test_integration_recalls_from_chat_history_dom():
|
||||
_arrowUpRecallWired: false,
|
||||
addEventListener(type, fn) {{ if (type === 'keydown') listeners.push(fn); }},
|
||||
}};
|
||||
wireArrowUpRecall(composer, () => getLastUserMessageFromChatHistory(doc));
|
||||
wireArrowUpRecall(composer, () => getUserMessagesFromChatHistory(doc));
|
||||
let prevented = false;
|
||||
listeners[0]({{
|
||||
key: 'ArrowUp',
|
||||
|
||||
@@ -51,7 +51,7 @@ def test_plan_mode_classifies_every_email_tool():
|
||||
from src.tool_security import plan_mode_disabled_tools
|
||||
|
||||
denied = plan_mode_disabled_tools()
|
||||
readonly = {"list_email_accounts", "list_emails", "read_email", "search_emails"}
|
||||
readonly = {"list_email_accounts", "list_emails", "read_email", "search_emails", "scan_email_unsubscribes"}
|
||||
for tool in sorted(BUILTIN_EMAIL_TOOLS):
|
||||
if tool in readonly:
|
||||
assert tool in PLAN_MODE_READONLY_TOOLS, f"{tool} must be explicit read-only"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from email.message import EmailMessage
|
||||
|
||||
from routes.email_routes import (
|
||||
_dedupe_unsubscribe_candidates,
|
||||
_email_unsubscribe_candidate_from_msg,
|
||||
_parse_list_unsubscribe_header,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_list_unsubscribe_mailto_and_url():
|
||||
methods = _parse_list_unsubscribe_header(
|
||||
'<mailto:list@example.com?subject=unsubscribe&body=remove%20me>, '
|
||||
'<https://example.com/unsubscribe/token>'
|
||||
)
|
||||
|
||||
assert methods == [
|
||||
{
|
||||
"kind": "mailto",
|
||||
"target": "list@example.com",
|
||||
"subject": "unsubscribe",
|
||||
"body": "remove me",
|
||||
"executable": True,
|
||||
},
|
||||
{
|
||||
"kind": "url",
|
||||
"target": "https://example.com/unsubscribe/token",
|
||||
"executable": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_unsubscribe_candidate_requires_unsubscribe_header():
|
||||
msg = EmailMessage()
|
||||
msg["From"] = "Shop <deals@example.com>"
|
||||
msg["Subject"] = "Limited time discount"
|
||||
msg["Precedence"] = "bulk"
|
||||
|
||||
assert _email_unsubscribe_candidate_from_msg(msg, "12", "INBOX") is None
|
||||
|
||||
|
||||
def test_unsubscribe_candidate_scores_bulk_newsletter():
|
||||
msg = EmailMessage()
|
||||
msg["From"] = "Shop <deals@example.com>"
|
||||
msg["Subject"] = "Limited time discount"
|
||||
msg["Precedence"] = "bulk"
|
||||
msg["List-Id"] = "Shop Deals <deals.example.com>"
|
||||
msg["List-Unsubscribe"] = "<mailto:unsubscribe@example.com?subject=unsubscribe>"
|
||||
|
||||
candidate = _email_unsubscribe_candidate_from_msg(
|
||||
msg,
|
||||
"12",
|
||||
"INBOX",
|
||||
spam_cached={"spam": True, "reason": "marketing blast"},
|
||||
)
|
||||
|
||||
assert candidate is not None
|
||||
assert candidate["uid"] == "12"
|
||||
assert candidate["can_execute"] is True
|
||||
assert candidate["recommended_method"]["kind"] == "mailto"
|
||||
assert "marketing blast" in candidate["reasons"]
|
||||
|
||||
|
||||
def test_dedupe_unsubscribe_candidates_collapses_same_list():
|
||||
first = EmailMessage()
|
||||
first["From"] = "Shop <deals@example.com>"
|
||||
first["Subject"] = "Sale one"
|
||||
first["List-Id"] = "Shop Deals <deals.example.com>"
|
||||
first["List-Unsubscribe"] = "<mailto:unsubscribe@example.com?subject=unsubscribe>"
|
||||
|
||||
second = EmailMessage()
|
||||
second["From"] = "Shop <deals@example.com>"
|
||||
second["Subject"] = "Sale two"
|
||||
second["List-Id"] = "Shop Deals <deals.example.com>"
|
||||
second["List-Unsubscribe"] = "<mailto:unsubscribe@example.com?subject=unsubscribe>"
|
||||
|
||||
candidates = [
|
||||
_email_unsubscribe_candidate_from_msg(first, "12", "INBOX"),
|
||||
_email_unsubscribe_candidate_from_msg(second, "13", "INBOX"),
|
||||
]
|
||||
|
||||
deduped = _dedupe_unsubscribe_candidates(candidates)
|
||||
|
||||
assert len(deduped) == 1
|
||||
assert deduped[0]["duplicate_count"] == 2
|
||||
assert deduped[0]["duplicate_uids"] == ["12", "13"]
|
||||
@@ -1,7 +1,12 @@
|
||||
from services.hwfit.image_models import rank_image_models, IMAGE_MODEL_REGISTRY
|
||||
from services.hwfit import image_models
|
||||
|
||||
rank_image_models = image_models.rank_image_models
|
||||
IMAGE_MODEL_REGISTRY = image_models.IMAGE_MODEL_REGISTRY
|
||||
|
||||
|
||||
def test_rank_image_models_handles_non_dict_system():
|
||||
def test_rank_image_models_handles_non_dict_system(monkeypatch):
|
||||
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [])
|
||||
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
|
||||
# `system` is the detected-hardware dict; if detection failed and returned
|
||||
# None (or a non-dict), system.get(...) raised AttributeError. Treat a
|
||||
# non-dict system as "unknown hardware" (no GPU) rather than crashing.
|
||||
|
||||
@@ -1,15 +1,142 @@
|
||||
from services.hwfit.image_models import rank_image_models, IMAGE_MODEL_REGISTRY
|
||||
from services.hwfit import image_models
|
||||
|
||||
rank_image_models = image_models.rank_image_models
|
||||
IMAGE_MODEL_REGISTRY = image_models.IMAGE_MODEL_REGISTRY
|
||||
|
||||
SYS = {"gpu_vram_gb": 0, "has_gpu": False}
|
||||
|
||||
|
||||
def test_rank_image_models_handles_non_string_search():
|
||||
def _disable_hf_discovery(monkeypatch):
|
||||
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [])
|
||||
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
|
||||
|
||||
|
||||
def test_rank_image_models_handles_non_string_search(monkeypatch):
|
||||
_disable_hf_discovery(monkeypatch)
|
||||
# search is a CLI/API filter arg; a non-string made search.lower() raise
|
||||
# AttributeError. A non-string search should behave as "no filter".
|
||||
out = rank_image_models(SYS, search=123)
|
||||
assert len(out) == len(IMAGE_MODEL_REGISTRY)
|
||||
|
||||
|
||||
def test_rank_image_models_string_filter_still_applies():
|
||||
def test_rank_image_models_string_filter_still_applies(monkeypatch):
|
||||
_disable_hf_discovery(monkeypatch)
|
||||
out = rank_image_models(SYS, search="zzzznotarealmodelzzz")
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_rank_image_models_uses_ram_budget_when_gpu_disabled(monkeypatch):
|
||||
model = {
|
||||
"id": "example-org/example-image-model",
|
||||
"name": "Example Image Model",
|
||||
"provider": "example-org",
|
||||
"params_b": 20.0,
|
||||
"vram_bf16": 42.0,
|
||||
"vram_fp8": 22.0,
|
||||
"vram_q4": 14.0,
|
||||
"default_quant": "FP8",
|
||||
"quant_repos": {},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "Imported from test fixture.",
|
||||
"quality": 80,
|
||||
"speed": 50,
|
||||
}
|
||||
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [model])
|
||||
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
|
||||
|
||||
gpu_out = rank_image_models({"has_gpu": True, "gpu_vram_gb": 8, "available_ram_gb": 64}, search="Example Image")
|
||||
ram_out = rank_image_models({"has_gpu": False, "gpu_vram_gb": 0, "available_ram_gb": 64}, search="Example Image")
|
||||
|
||||
gpu_model = next(m for m in gpu_out if m["id"] == "example-org/example-image-model")
|
||||
ram_model = next(m for m in ram_out if m["id"] == "example-org/example-image-model")
|
||||
|
||||
assert gpu_model["fit"] == "no_fit"
|
||||
assert gpu_model["quant"] == "FP8"
|
||||
assert gpu_model["fit_budget"] == "gpu"
|
||||
assert ram_model["fit"] in {"good", "perfect"}
|
||||
assert ram_model["quant"] == "BF16"
|
||||
assert ram_model["fit_budget"] == "ram"
|
||||
|
||||
|
||||
def test_mlx_image_collection_models_only_show_on_apple(monkeypatch):
|
||||
mlx_model = {
|
||||
"id": "mlx-community/example-apple-image-model",
|
||||
"name": "Example Apple Image Model",
|
||||
"provider": "mlx-community",
|
||||
"params_b": 4.0,
|
||||
"vram_bf16": 10.0,
|
||||
"vram_fp8": None,
|
||||
"vram_q4": None,
|
||||
"default_quant": "BF16",
|
||||
"quant_repos": {},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "Apple Silicon / MLX only.",
|
||||
"quality": 82,
|
||||
"speed": 88,
|
||||
"mlx_only": True,
|
||||
}
|
||||
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [mlx_model])
|
||||
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
|
||||
|
||||
cuda = rank_image_models({"has_gpu": True, "gpu_vram_gb": 48, "backend": "cuda"}, search="Example Apple")
|
||||
metal = rank_image_models(
|
||||
{"has_gpu": True, "gpu_vram_gb": 48, "backend": "metal", "unified_memory": True},
|
||||
search="Example Apple",
|
||||
)
|
||||
|
||||
assert cuda == []
|
||||
assert [m["id"] for m in metal] == ["mlx-community/example-apple-image-model"]
|
||||
|
||||
|
||||
def test_apple_image_mode_hides_non_mlx_models(monkeypatch):
|
||||
model = {
|
||||
"id": "example-org/example-image-model",
|
||||
"name": "Example Image Model",
|
||||
"provider": "example-org",
|
||||
"params_b": 4.0,
|
||||
"vram_bf16": 8.0,
|
||||
"vram_fp8": None,
|
||||
"vram_q4": None,
|
||||
"default_quant": "BF16",
|
||||
"quant_repos": {},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "Imported from test fixture.",
|
||||
"quality": 80,
|
||||
"speed": 80,
|
||||
}
|
||||
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [model])
|
||||
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
|
||||
|
||||
metal = rank_image_models(
|
||||
{"has_gpu": True, "gpu_vram_gb": 48, "backend": "metal", "unified_memory": True},
|
||||
search="Example Image",
|
||||
)
|
||||
cuda = rank_image_models(
|
||||
{"has_gpu": True, "gpu_vram_gb": 48, "backend": "cuda"},
|
||||
search="Example Image",
|
||||
)
|
||||
|
||||
assert metal == []
|
||||
assert [m["id"] for m in cuda] == ["example-org/example-image-model"]
|
||||
|
||||
|
||||
def test_mlx_collection_imports_show_on_metal_not_cuda(monkeypatch):
|
||||
mlx_model = image_models._collection_item_to_model(
|
||||
{"id": "mlx-community/example-image-model-4bit"},
|
||||
"Example Apple image collection",
|
||||
mlx_only=True,
|
||||
)
|
||||
monkeypatch.setattr(image_models, "_fetch_hf_image_collection_models", lambda: [mlx_model])
|
||||
monkeypatch.setattr(image_models, "_discover_quant_repos", lambda *a, **k: {})
|
||||
|
||||
metal = rank_image_models(
|
||||
{"has_gpu": True, "gpu_vram_gb": 64, "backend": "metal", "unified_memory": True},
|
||||
search="example-image-model",
|
||||
)
|
||||
cuda = rank_image_models(
|
||||
{"has_gpu": True, "gpu_vram_gb": 64, "backend": "cuda"},
|
||||
search="example-image-model",
|
||||
)
|
||||
|
||||
assert [m["id"] for m in metal] == ["mlx-community/example-image-model-4bit"]
|
||||
assert cuda == []
|
||||
|
||||
@@ -92,7 +92,7 @@ def test_strips_every_named_email_tool_fence():
|
||||
email_tools = [
|
||||
"list_email_accounts", "send_email", "list_emails", "read_email",
|
||||
"reply_to_email", "bulk_email", "archive_email", "delete_email",
|
||||
"mark_email_read",
|
||||
"mark_email_read", "scan_email_unsubscribes", "unsubscribe_email",
|
||||
]
|
||||
for tool in email_tools:
|
||||
fence = f"```{tool}\n{{}}\n```"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from src import llm_core
|
||||
|
||||
|
||||
def _tool():
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"description": "search",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_openai_chat_tools_force_gpt5_reasoning_effort_none():
|
||||
payload = {"tools": [_tool()], "reasoning_effort": "high"}
|
||||
|
||||
llm_core._scrub_openai_chat_tool_reasoning(
|
||||
payload,
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"gpt-5.6-luna",
|
||||
)
|
||||
|
||||
assert payload["reasoning_effort"] == "none"
|
||||
|
||||
|
||||
def test_openai_chat_tools_match_gpt5_variants():
|
||||
for model in ["gpt-5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "openai/gpt-5.6-luna"]:
|
||||
payload = {"tools": [_tool()], "reasoning_effort": "medium"}
|
||||
|
||||
llm_core._scrub_openai_chat_tool_reasoning(
|
||||
payload,
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
model,
|
||||
)
|
||||
|
||||
assert payload["reasoning_effort"] == "none"
|
||||
|
||||
|
||||
def test_openai_chat_no_tools_leaves_reasoning_effort_unchanged():
|
||||
payload = {"reasoning_effort": "high"}
|
||||
|
||||
llm_core._scrub_openai_chat_tool_reasoning(
|
||||
payload,
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"gpt-5.6-luna",
|
||||
)
|
||||
|
||||
assert payload["reasoning_effort"] == "high"
|
||||
|
||||
|
||||
def test_non_openai_host_leaves_reasoning_effort_unchanged():
|
||||
payload = {"tools": [_tool()], "reasoning_effort": "high"}
|
||||
|
||||
llm_core._scrub_openai_chat_tool_reasoning(
|
||||
payload,
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
"openai/gpt-5.6-luna",
|
||||
)
|
||||
|
||||
assert payload["reasoning_effort"] == "high"
|
||||
|
||||
|
||||
def test_non_gpt5_model_leaves_reasoning_effort_unchanged():
|
||||
payload = {"tools": [_tool()], "reasoning_effort": "high"}
|
||||
|
||||
llm_core._scrub_openai_chat_tool_reasoning(
|
||||
payload,
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"gpt-4.1",
|
||||
)
|
||||
|
||||
assert payload["reasoning_effort"] == "high"
|
||||
@@ -91,14 +91,14 @@ def test_local_minimax_mlx_payload_gets_stability_defaults(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(model_context, "is_local_endpoint", lambda _url: True)
|
||||
payload = {
|
||||
"model": "cookietimeh/MiniMax-M2.7-BF16-ultra-uncensored-heretic-mlx-4Bit",
|
||||
"model": "example-org/MiniMax-M2.7-BF16-mlx-4Bit",
|
||||
"temperature": 0.9,
|
||||
}
|
||||
|
||||
llm_core._apply_local_generation_stability(
|
||||
payload,
|
||||
"http://192.168.1.22:8091/v1/chat/completions",
|
||||
"cookietimeh/MiniMax-M2.7-BF16-ultra-uncensored-heretic-mlx-4Bit",
|
||||
"example-org/MiniMax-M2.7-BF16-mlx-4Bit",
|
||||
)
|
||||
|
||||
assert payload["temperature"] == 0.2
|
||||
|
||||
@@ -24,3 +24,21 @@ def test_header_indicator_has_title_tooltip():
|
||||
body = SRC[SRC.index("export function updateModelPicker()"):]
|
||||
assert re.search(r"label\.title\s*=\s*modelId\b", body), \
|
||||
"header model indicator needs a title tooltip (#1982)"
|
||||
|
||||
|
||||
def test_api_picker_dedupe_includes_endpoint_id():
|
||||
# API providers can expose the same model id intentionally. The chat picker
|
||||
# must not dedupe OpenRouter away just because OpenAI has the same id.
|
||||
assert "const isApiEndpoint = item.category && item.category !== 'local';" in SRC
|
||||
assert re.search(r"const seenKey = isApiEndpoint\s*\?", SRC), \
|
||||
"chat picker should dedupe API models by endpoint+model, not model id only"
|
||||
assert "${item.endpoint_id || item.url || item.endpoint_name || 'api'}::${mid}" in SRC
|
||||
|
||||
|
||||
def test_api_picker_groups_by_endpoint_name():
|
||||
# OpenRouter models often have ids like openai/* or google/*; browse mode
|
||||
# should still show them under the OpenRouter endpoint group.
|
||||
assert "function _providerGroupKey(m)" in SRC
|
||||
assert "m.category && m.category !== 'local' && m.epName" in SRC
|
||||
assert "`~endpoint:${m.epName}`" in SRC
|
||||
assert "_providerGroupName(provider)" in SRC
|
||||
|
||||
+188
-3
@@ -439,6 +439,9 @@ class TestClassifyEndpoint:
|
||||
def test_public_api(self):
|
||||
assert _classify_endpoint("https://api.openai.com/v1") == "api"
|
||||
|
||||
def test_openrouter_api(self):
|
||||
assert _classify_endpoint("https://openrouter.ai/api/v1") == "api"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _classify_endpoint("") == "api"
|
||||
|
||||
@@ -1010,6 +1013,44 @@ def test_patch_models_pinned_does_not_clobber_hidden(monkeypatch):
|
||||
assert json.loads(ep.pinned_models) == ["deploy-1"]
|
||||
|
||||
|
||||
def test_patch_api_hidden_payload_converts_to_pinned(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
cached_models=json.dumps(["m1", "m2", "m3"]),
|
||||
pinned_models=None,
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "PATCH")
|
||||
|
||||
result = asyncio.run(endpoint("ep1", _PinnedFakeRequest(body={"hidden": ["m2"]})))
|
||||
|
||||
assert result["pinned_count"] == 2
|
||||
assert result["hidden_count"] == 0
|
||||
assert json.loads(ep.pinned_models) == ["m1", "m3"]
|
||||
assert ep.hidden_models is None
|
||||
|
||||
|
||||
def test_patch_api_hidden_empty_pins_all_cached_models(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
cached_models=json.dumps(["m1", "m2", "m3"]),
|
||||
pinned_models=None,
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "PATCH")
|
||||
|
||||
result = asyncio.run(endpoint("ep1", _PinnedFakeRequest(body={"hidden": []})))
|
||||
|
||||
assert result["pinned_count"] == 3
|
||||
assert result["hidden_count"] == 0
|
||||
assert json.loads(ep.pinned_models) == ["m1", "m2", "m3"]
|
||||
assert ep.hidden_models is None
|
||||
|
||||
|
||||
def test_get_models_returns_pinned_when_probe_empty(monkeypatch):
|
||||
ep = _make_endpoint(pinned_models=json.dumps(["deploy-1"]))
|
||||
db = _PinnedFakeDb([ep])
|
||||
@@ -1025,6 +1066,26 @@ def test_get_models_returns_pinned_when_probe_empty(monkeypatch):
|
||||
assert result[0]["is_pinned"] is True
|
||||
|
||||
|
||||
def test_get_api_models_marks_picker_as_pinned_only(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://api.example.test/v1",
|
||||
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
|
||||
pinned_models=json.dumps(["openai/gpt-image-1"]),
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "GET")
|
||||
|
||||
result = endpoint("ep1", _PinnedFakeRequest(), SimpleNamespace(headers={}))
|
||||
|
||||
by_id = {row["id"]: row for row in result}
|
||||
assert by_id["openai/gpt-image-1"]["picker_requires_pinning"] is True
|
||||
assert by_id["openai/gpt-image-1"]["is_pinned"] is True
|
||||
assert by_id["anthropic/claude-sonnet-4"]["picker_requires_pinning"] is True
|
||||
assert by_id["anthropic/claude-sonnet-4"]["is_pinned"] is False
|
||||
|
||||
|
||||
def test_reprobe_preserves_pinned_models(monkeypatch):
|
||||
ep = _make_endpoint(pinned_models=json.dumps(["deploy-1"]))
|
||||
db = _PinnedFakeDb([ep])
|
||||
@@ -1184,6 +1245,79 @@ def test_list_model_endpoints_returns_key_fingerprint(monkeypatch):
|
||||
assert result[1]["api_key_fingerprint"] == ""
|
||||
|
||||
|
||||
def test_list_api_endpoint_reports_inventory_count_when_none_pinned(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://api.example.test/v1",
|
||||
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
|
||||
pinned_models=None,
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints", "GET")
|
||||
|
||||
result = endpoint(_PinnedFakeRequest())
|
||||
|
||||
assert result[0]["models"] == []
|
||||
assert result[0]["model_count"] == 2
|
||||
assert result[0]["picker_requires_pinning"] is True
|
||||
assert result[0]["status"] == "online"
|
||||
|
||||
|
||||
def test_list_api_endpoint_returns_pinned_picker_models(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://api.example.test/v1",
|
||||
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
|
||||
pinned_models=json.dumps(["openai/gpt-image-1"]),
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints", "GET")
|
||||
|
||||
result = endpoint(_PinnedFakeRequest())
|
||||
|
||||
assert result[0]["models"] == ["openai/gpt-image-1"]
|
||||
assert result[0]["pinned_models"] == ["openai/gpt-image-1"]
|
||||
assert result[0]["model_count"] == 2
|
||||
|
||||
|
||||
def test_list_api_endpoint_pinned_models_ignore_stale_hidden_state(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://api.example.test/v1",
|
||||
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
|
||||
hidden_models=json.dumps(["openai/gpt-image-1"]),
|
||||
pinned_models=json.dumps(["openai/gpt-image-1"]),
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints", "GET")
|
||||
|
||||
result = endpoint(_PinnedFakeRequest())
|
||||
|
||||
assert result[0]["models"] == ["openai/gpt-image-1"]
|
||||
|
||||
|
||||
def test_list_api_endpoint_derives_pins_from_legacy_hidden_state(monkeypatch):
|
||||
ep = _make_endpoint(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
cached_models=json.dumps(["m1", "m2", "m3"]),
|
||||
hidden_models=json.dumps(["m2"]),
|
||||
pinned_models=None,
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
endpoint = _get_route("/api/model-endpoints", "GET")
|
||||
|
||||
result = endpoint(_PinnedFakeRequest())
|
||||
|
||||
assert result[0]["models"] == ["m1", "m3"]
|
||||
assert result[0]["pinned_models"] == ["m1", "m3"]
|
||||
assert json.loads(ep.pinned_models) == ["m1", "m3"]
|
||||
|
||||
|
||||
def test_post_creates_endpoint_with_pinned_models(monkeypatch):
|
||||
db = _PinnedFakeDb([]) # no existing row → fresh create path
|
||||
_patch_create_deps(monkeypatch, db)
|
||||
@@ -1553,11 +1687,12 @@ def test_api_models_scopes_api_token_to_token_owner(monkeypatch):
|
||||
assert admin_checks == ["alice"]
|
||||
|
||||
|
||||
def test_api_models_returns_cached_proxy_models_without_refresh_probe(monkeypatch):
|
||||
def test_api_models_returns_only_pinned_proxy_models_without_refresh_probe(monkeypatch):
|
||||
row = _route_ep(
|
||||
"proxy",
|
||||
"http://100.117.136.97:34521/v1",
|
||||
cached_models=["cached-model"],
|
||||
cached_models=["cached-model", "other-model"],
|
||||
pinned_models=["cached-model"],
|
||||
endpoint_kind="proxy",
|
||||
api_key="fake-key",
|
||||
refresh_mode="manual",
|
||||
@@ -1579,10 +1714,60 @@ def test_api_models_returns_cached_proxy_models_without_refresh_probe(monkeypatc
|
||||
result = _route_endpoint(router, "/api/models")(_route_request())
|
||||
|
||||
assert result["items"][0]["models"] == ["cached-model"]
|
||||
assert result["items"][0]["models_extra"] == []
|
||||
assert result["items"][0]["category"] == "api"
|
||||
assert result["items"][0]["endpoint_kind"] == "proxy"
|
||||
assert "offline" not in result["items"][0]
|
||||
assert json.loads(row.cached_models) == ["cached-model"]
|
||||
assert json.loads(row.cached_models) == ["cached-model", "other-model"]
|
||||
|
||||
|
||||
def test_api_models_openrouter_uses_pinned_models_not_hidden(monkeypatch):
|
||||
row = _route_ep(
|
||||
"openrouter",
|
||||
"https://openrouter.ai/api/v1",
|
||||
cached_models=["openai/gpt-image-1", "anthropic/claude-sonnet-4"],
|
||||
pinned_models=["openai/gpt-image-1"],
|
||||
api_key="fake-key",
|
||||
)
|
||||
row.hidden_models = json.dumps(["openai/gpt-image-1"])
|
||||
db = _RouteDb([row])
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "_auth_disabled", lambda: True)
|
||||
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat/completions")
|
||||
monkeypatch.setattr(threading, "Thread", _NoopThread)
|
||||
|
||||
result = _route_endpoint(router, "/api/models")(_route_request())
|
||||
|
||||
assert result["items"][0]["endpoint_name"] == "openrouter"
|
||||
assert result["items"][0]["category"] == "api"
|
||||
assert result["items"][0]["models"] == ["openai/gpt-image-1"]
|
||||
|
||||
|
||||
def test_api_models_openrouter_derives_legacy_visible_models(monkeypatch):
|
||||
row = _route_ep(
|
||||
"openrouter",
|
||||
"https://openrouter.ai/api/v1",
|
||||
cached_models=["m1", "m2", "m3"],
|
||||
pinned_models=None,
|
||||
api_key="fake-key",
|
||||
)
|
||||
row.hidden_models = json.dumps(["m2"])
|
||||
db = _RouteDb([row])
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "_auth_disabled", lambda: True)
|
||||
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat/completions")
|
||||
monkeypatch.setattr(threading, "Thread", _NoopThread)
|
||||
|
||||
result = _route_endpoint(router, "/api/models")(_route_request())
|
||||
|
||||
assert result["items"][0]["endpoint_name"] == "openrouter"
|
||||
assert result["items"][0]["models"] == ["m1", "m3"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -354,6 +354,7 @@ async def test_build_chat_context_incognito_does_not_duplicate_current_user_mess
|
||||
monkeypatch.setitem(sys.modules, mod_name, MagicMock())
|
||||
|
||||
chat_helpers = importlib.import_module("routes.chat_helpers")
|
||||
chat_helpers._INCOGNITO_CONTEXTS.clear()
|
||||
|
||||
async def fake_preprocess(chat_handler, message, att_ids, sess, **kwargs):
|
||||
# **kwargs absorbs auto_opened_docs (added when PDF imports auto-create
|
||||
@@ -417,6 +418,68 @@ async def test_build_chat_context_incognito_does_not_duplicate_current_user_mess
|
||||
assert len(user_messages) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_chat_context_incognito_ignores_saved_session_history(monkeypatch):
|
||||
for mod_name in [
|
||||
"starlette.middleware",
|
||||
"starlette.middleware.base",
|
||||
"core.models",
|
||||
"core.database",
|
||||
"routes.prefs_routes",
|
||||
"routes.research_routes",
|
||||
"src.llm_core",
|
||||
"src.context_compactor",
|
||||
"src.model_context",
|
||||
"src.auth_helpers",
|
||||
]:
|
||||
if mod_name not in sys.modules:
|
||||
monkeypatch.setitem(sys.modules, mod_name, MagicMock())
|
||||
|
||||
chat_helpers = importlib.import_module("routes.chat_helpers")
|
||||
chat_helpers._INCOGNITO_CONTEXTS.clear()
|
||||
|
||||
async def fake_preprocess(chat_handler, message, att_ids, sess, **kwargs):
|
||||
return chat_helpers.PreprocessedMessage(
|
||||
enhanced_message=message,
|
||||
user_content=message,
|
||||
text_for_context=message,
|
||||
youtube_transcripts=[],
|
||||
attachment_meta=[],
|
||||
)
|
||||
|
||||
async def fake_maybe_compact(sess, endpoint_url, model, messages, headers, owner=None):
|
||||
return messages, 123, False
|
||||
|
||||
monkeypatch.setattr(chat_helpers, "preprocess", fake_preprocess)
|
||||
monkeypatch.setattr(chat_helpers, "extract_preset", lambda *_args, **_kwargs: chat_helpers.PresetInfo(0.7, 1024, None, None))
|
||||
monkeypatch.setattr(chat_helpers, "load_prefs_for_user", lambda user: {})
|
||||
monkeypatch.setattr(chat_helpers, "effective_user", lambda request: "tester")
|
||||
monkeypatch.setattr(chat_helpers, "normalize_model_id", lambda endpoint_url, model, **kwargs: None)
|
||||
monkeypatch.setattr(chat_helpers, "maybe_compact", fake_maybe_compact)
|
||||
monkeypatch.setattr(chat_helpers, "trim_for_context", lambda messages, context_length: messages)
|
||||
|
||||
sess = SimpleNamespace(
|
||||
endpoint_url="http://localhost:8000/v1",
|
||||
model="test-model",
|
||||
headers={},
|
||||
get_context_messages=lambda: [{"role": "user", "content": "older non-incognito secret"}],
|
||||
)
|
||||
chat_processor = SimpleNamespace(build_context_preface=lambda **kwargs: ([], [], []))
|
||||
|
||||
ctx = await chat_helpers.build_chat_context(
|
||||
sess=sess,
|
||||
request=SimpleNamespace(),
|
||||
chat_handler=SimpleNamespace(),
|
||||
chat_processor=chat_processor,
|
||||
message="fresh incognito turn",
|
||||
session_id="s-incog",
|
||||
incognito=True,
|
||||
)
|
||||
|
||||
assert {"role": "user", "content": "fresh incognito turn"} in ctx.messages
|
||||
assert all(m.get("content") != "older non-incognito secret" for m in ctx.messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_agent_tools_require_admin(monkeypatch):
|
||||
auth_mod = _install_core_auth_stub(monkeypatch)
|
||||
@@ -648,6 +711,7 @@ async def test_public_agent_policy_blocks_sensitive_tools(monkeypatch):
|
||||
# here instead of silently shrinking the blocklist.
|
||||
bare_email_tools = (
|
||||
"list_email_accounts", "list_emails", "read_email", "search_emails",
|
||||
"scan_email_unsubscribes", "unsubscribe_email",
|
||||
"send_email", "reply_to_email", "draft_email", "draft_email_reply",
|
||||
"ai_draft_email_reply", "archive_email", "delete_email",
|
||||
"mark_email_read", "bulk_email", "download_attachment",
|
||||
@@ -758,6 +822,7 @@ async def test_disable_tool_email_covers_full_builtin_set(monkeypatch):
|
||||
# from the constant fails here instead of silently shrinking the toggle.
|
||||
bare_email_tools = (
|
||||
"list_email_accounts", "list_emails", "read_email", "search_emails",
|
||||
"scan_email_unsubscribes", "unsubscribe_email",
|
||||
"send_email", "reply_to_email", "draft_email", "draft_email_reply",
|
||||
"ai_draft_email_reply", "archive_email", "delete_email",
|
||||
"mark_email_read", "bulk_email", "download_attachment",
|
||||
@@ -930,7 +995,7 @@ async def test_plan_mode_blocks_mutating_email_aliases_without_mcp_inventory(mon
|
||||
denied = plan_mode_disabled_tools()
|
||||
|
||||
for tool_name in ("draft_email", "draft_email_reply", "ai_draft_email_reply",
|
||||
"download_attachment", "send_email", "delete_email"):
|
||||
"download_attachment", "send_email", "delete_email", "unsubscribe_email"):
|
||||
desc, result = await execute_tool_block(
|
||||
SimpleNamespace(tool_type=tool_name, content="{}"),
|
||||
owner="admin-user",
|
||||
@@ -949,6 +1014,17 @@ async def test_plan_mode_blocks_mutating_email_aliases_without_mcp_inventory(mon
|
||||
("mcp__email__search_emails", {"query": "x", "_odysseus_owner": "admin-user"}),
|
||||
]
|
||||
|
||||
mcp.calls.clear()
|
||||
desc, result = await execute_tool_block(
|
||||
SimpleNamespace(tool_type="scan_email_unsubscribes", content='{"limit": 1}'),
|
||||
owner="admin-user",
|
||||
disabled_tools=denied,
|
||||
)
|
||||
assert result["exit_code"] == 0
|
||||
assert mcp.calls == [
|
||||
("mcp__email__scan_email_unsubscribes", {"limit": 1, "_odysseus_owner": "admin-user"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_email_dispatch_empty_content_calls_with_empty_args(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import json
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from core.database import Base, ChatMessage, GalleryImage, Session
|
||||
from src import session_image_cleanup
|
||||
|
||||
|
||||
def test_cleanup_session_images_deactivates_gallery_rows_and_unlinks_files(tmp_path, monkeypatch):
|
||||
image_dir = tmp_path / "generated_images"
|
||||
image_dir.mkdir()
|
||||
linked_file = image_dir / "aaaaaaaaaaaa.png"
|
||||
event_file = image_dir / "bbbbbbbbbbbb.png"
|
||||
linked_file.write_bytes(b"linked")
|
||||
event_file.write_bytes(b"event")
|
||||
monkeypatch.setattr(session_image_cleanup, "GENERATED_IMAGES_DIR", str(image_dir))
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'cleanup.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(Session(id="chat-1", name="Image chat", endpoint_url="http://local", model="image-model", owner="alice"))
|
||||
db.add(
|
||||
GalleryImage(
|
||||
id="img-linked",
|
||||
filename=linked_file.name,
|
||||
prompt="linked",
|
||||
owner="alice",
|
||||
session_id="chat-1",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
GalleryImage(
|
||||
id="img-event",
|
||||
filename=event_file.name,
|
||||
prompt="event",
|
||||
owner="alice",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
ChatMessage(
|
||||
id="msg-1",
|
||||
session_id="chat-1",
|
||||
role="assistant",
|
||||
content="Generated image",
|
||||
meta_data=json.dumps(
|
||||
{
|
||||
"tool_events": [
|
||||
{
|
||||
"image_id": "img-event",
|
||||
"image_url": f"/api/generated-image/{event_file.name}",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
removed = session_image_cleanup.cleanup_session_images("chat-1", db=db)
|
||||
|
||||
assert removed == 2
|
||||
assert not linked_file.exists()
|
||||
assert not event_file.exists()
|
||||
assert db.query(GalleryImage).filter_by(id="img-linked").first().is_active is False
|
||||
assert db.query(GalleryImage).filter_by(id="img-event").first().is_active is False
|
||||
finally:
|
||||
db.close()
|
||||
@@ -24,7 +24,7 @@ import inspect
|
||||
|
||||
import src.tool_implementations as ti
|
||||
|
||||
# 33 do_* tool functions
|
||||
# Historical do_* tool functions.
|
||||
_EXPECTED = [
|
||||
"do_adopt_served_model", "do_api_call", "do_app_api", "do_cancel_download",
|
||||
"do_download_model", "do_edit_image", "do_list_cached_models",
|
||||
|
||||
@@ -32,7 +32,7 @@ def test_tell_in_web_query_does_not_force_email_tools():
|
||||
"""The #1707 repro: a web request that merely contains the word 'tell' must
|
||||
NOT drag in the email toolset."""
|
||||
ti = _index_without_embeddings()
|
||||
q = "visit https://www.youtube.com/user/PewDiePie and tell me the title of his latest video"
|
||||
q = "visit https://www.youtube.com/user/example and tell me the title of the latest video"
|
||||
tools = ti.get_tools_for_query(q)
|
||||
leaked = _EMAIL_TOOLS & tools
|
||||
assert not leaked, f"'tell me' must not force-include email tools, got {sorted(leaked)}"
|
||||
|
||||
@@ -125,6 +125,67 @@ async def test_read_write_edit_confined_e2e(ws, admin):
|
||||
assert not os.path.exists(escape)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_confined_e2e(ws, admin):
|
||||
with open(os.path.join(ws, "patchme.txt"), "w") as f:
|
||||
f.write("alpha\nbeta\ngamma\n")
|
||||
patch = """*** Begin Patch
|
||||
*** Update File: patchme.txt
|
||||
@@
|
||||
alpha
|
||||
-beta
|
||||
+BETA
|
||||
gamma
|
||||
*** Add File: added.txt
|
||||
+new file
|
||||
*** End Patch"""
|
||||
_, r = await execute_tool_block(_block("apply_patch", patch), owner="a", workspace=ws)
|
||||
assert r["exit_code"] == 0
|
||||
assert r["diff"]["added"] >= 2
|
||||
with open(os.path.join(ws, "patchme.txt")) as f:
|
||||
assert f.read() == "alpha\nBETA\ngamma\n"
|
||||
with open(os.path.join(ws, "added.txt")) as f:
|
||||
assert f.read() == "new file\n"
|
||||
|
||||
outside = tempfile.mkdtemp()
|
||||
outside_file = os.path.join(outside, "x.txt")
|
||||
with open(outside_file, "w") as f:
|
||||
f.write("x\n")
|
||||
escape_patch = f"""*** Begin Patch
|
||||
*** Update File: {outside_file}
|
||||
@@
|
||||
-x
|
||||
+y
|
||||
*** End Patch"""
|
||||
_, r = await execute_tool_block(_block("apply_patch", escape_patch), owner="a", workspace=ws)
|
||||
assert r["exit_code"] == 1 and "outside the workspace" in r["error"]
|
||||
with open(outside_file) as f:
|
||||
assert f.read() == "x\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_todowrite_persists_session_list(tmp_path, monkeypatch, admin):
|
||||
import src.agent_tools.coding_tools as coding_tools
|
||||
|
||||
monkeypatch.setattr(coding_tools, "_TODO_DIR", str(tmp_path))
|
||||
payload = {
|
||||
"todos": [
|
||||
{"content": "Inspect code", "status": "completed", "priority": "high"},
|
||||
{"content": "Patch code", "status": "in_progress", "priority": "high"},
|
||||
]
|
||||
}
|
||||
_, r = await execute_tool_block(
|
||||
_block("todowrite", json.dumps(payload)),
|
||||
session_id="chat/one",
|
||||
owner="a",
|
||||
workspace=str(tmp_path),
|
||||
)
|
||||
assert r["exit_code"] == 0
|
||||
assert "[>] Patch code" in r["output"]
|
||||
saved = json.load(open(tmp_path / "chat_one.json", encoding="utf-8"))
|
||||
assert saved["todos"][1]["status"] == "in_progress"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_and_ls_confined_e2e(ws, admin):
|
||||
with open(os.path.join(ws, "doc.txt"), "w") as f:
|
||||
@@ -231,7 +292,7 @@ async def test_binding_does_not_leak(ws, admin):
|
||||
# must still surface the file tools, otherwise the agent says it has no file
|
||||
# access (the bug this guards against).
|
||||
|
||||
def _sent_tool_names(monkeypatch, *, workspace):
|
||||
def _sent_tool_names(monkeypatch, *, workspace, message="look at the local project", force_keyword_fallback=False):
|
||||
import asyncio
|
||||
import src.agent_loop as al
|
||||
|
||||
@@ -240,6 +301,13 @@ def _sent_tool_names(monkeypatch, *, workspace):
|
||||
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
|
||||
# Isolate the selection logic from owner gating (tested separately).
|
||||
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
|
||||
if force_keyword_fallback:
|
||||
import src.tool_index as ti
|
||||
|
||||
def _raise_get_tool_index():
|
||||
raise RuntimeError("skip vector retrieval")
|
||||
|
||||
monkeypatch.setattr(ti, "get_tool_index", _raise_get_tool_index, raising=False)
|
||||
|
||||
captured = []
|
||||
|
||||
@@ -253,7 +321,7 @@ def _sent_tool_names(monkeypatch, *, workspace):
|
||||
async def _run():
|
||||
gen = al.stream_agent_loop(
|
||||
"https://api.openai.com/v1", "gpt-test",
|
||||
[{"role": "user", "content": "look at the local project"}],
|
||||
[{"role": "user", "content": message}],
|
||||
max_rounds=1, relevant_tools=None, owner="admin", workspace=workspace,
|
||||
)
|
||||
return [c async for c in gen]
|
||||
@@ -276,12 +344,84 @@ def test_low_signal_with_workspace_surfaces_readonly_file_tools(monkeypatch):
|
||||
assert "python" not in names
|
||||
|
||||
|
||||
def test_workspace_coding_request_surfaces_edit_and_verify_tools(monkeypatch):
|
||||
names = _sent_tool_names(
|
||||
monkeypatch,
|
||||
workspace="/tmp",
|
||||
message="fix the failing frontend test in this repo",
|
||||
force_keyword_fallback=True,
|
||||
)
|
||||
assert "get_workspace" in names
|
||||
assert "read_file" in names
|
||||
assert "grep" in names
|
||||
assert "edit_file" in names
|
||||
assert "write_file" in names
|
||||
assert "apply_patch" in names
|
||||
assert "todowrite" in names
|
||||
assert "bash" in names
|
||||
assert "python" in names
|
||||
|
||||
|
||||
def test_low_signal_without_workspace_excludes_file_tools(monkeypatch):
|
||||
names = _sent_tool_names(monkeypatch, workspace=None)
|
||||
assert "read_file" not in names
|
||||
assert "get_workspace" not in names
|
||||
|
||||
|
||||
def test_explicit_workspace_request_without_workspace_stops(monkeypatch):
|
||||
import asyncio
|
||||
import src.agent_loop as al
|
||||
|
||||
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
|
||||
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
||||
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
|
||||
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
|
||||
|
||||
async def _should_not_stream(*args, **kwargs):
|
||||
raise AssertionError("LLM should not be called when explicit workspace is missing")
|
||||
yield ""
|
||||
|
||||
monkeypatch.setattr(al, "stream_llm_with_fallback", _should_not_stream, raising=False)
|
||||
|
||||
async def _run():
|
||||
gen = al.stream_agent_loop(
|
||||
"https://api.openai.com/v1", "gpt-test",
|
||||
[{"role": "user", "content": "In this workspace, fix a typo and verify it."}],
|
||||
max_rounds=1, relevant_tools=None, owner="admin", workspace=None,
|
||||
)
|
||||
return [c async for c in gen]
|
||||
|
||||
chunks = asyncio.run(_run())
|
||||
text = "".join(chunks)
|
||||
assert "No active workspace is set" in text
|
||||
assert "/workspace set /absolute/path" in text
|
||||
assert '"missing_workspace": true' in text
|
||||
|
||||
|
||||
def test_workspace_coding_mode_prompt_is_injected(monkeypatch):
|
||||
import src.agent_loop as al
|
||||
|
||||
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
|
||||
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
||||
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
|
||||
al._cached_base_prompt = None
|
||||
al._cached_base_prompt_key = None
|
||||
|
||||
messages, _ = al._build_system_prompt(
|
||||
messages=[{"role": "user", "content": "fix the bug"}],
|
||||
model="gpt-test",
|
||||
active_document=None,
|
||||
mcp_mgr=None,
|
||||
relevant_tools={"get_workspace", "read_file", "grep", "edit_file", "write_file", "apply_patch", "todowrite", "bash"},
|
||||
workspace="/tmp/example-repo",
|
||||
)
|
||||
system_text = "\n\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
|
||||
assert "## Workspace coding mode" in system_text
|
||||
assert "Active workspace: `/tmp/example-repo`" in system_text
|
||||
assert "call `todowrite`" in system_text
|
||||
assert "Change repo files with `apply_patch`" in system_text
|
||||
|
||||
|
||||
# ── browse route is admin-gated ─────────────────────────────────────────
|
||||
|
||||
def test_browse_is_admin_gated(monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user