Merge commit '14afd3afb6274ff986733ddff992001e19c80e29' into fix/pr10-merge-ready

This commit is contained in:
Alexandre Teixeira
2026-09-23 12:29:27 +01:00
75 changed files with 625 additions and 269 deletions
+18 -1
View File
@@ -140,7 +140,24 @@ jobs:
cache: pip
- run: pip install -r requirements.txt
if: steps.docs-check.outputs.docs_only != 'true'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: steps.docs-check.outputs.docs_only != 'true'
with:
node-version: "20"
cache: npm
- run: npm ci
if: steps.docs-check.outputs.docs_only != 'true'
- run: npx playwright install --with-deps chromium
if: steps.docs-check.outputs.docs_only != 'true'
- run: mkdir -p data # sqlite DB lives at ./data/app.db
if: steps.docs-check.outputs.docs_only != 'true'
- run: python -m pytest -q
- name: Install FFmpeg for media integration tests
if: steps.docs-check.outputs.docs_only != 'true'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg
command -v ffmpeg
ffmpeg -version | head -n 1
- run: python -m pytest -q -rs
if: steps.docs-check.outputs.docs_only != 'true'
+10
View File
@@ -62,6 +62,8 @@ jobs:
- name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
with:
driver: docker
# Build without pushing so a broken Dockerfile is caught here, and the
# exact image we ship is what gets scanned.
@@ -73,6 +75,9 @@ jobs:
load: true
tags: odysseus:ci
- name: Free build cache before vulnerability database download
run: docker builder prune --all --force
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
@@ -103,6 +108,8 @@ jobs:
- name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
with:
driver: docker
- name: Build image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
@@ -112,6 +119,9 @@ jobs:
load: true
tags: odysseus:ci
- name: Free build cache before vulnerability database download
run: docker builder prune --all --force
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
+10 -4
View File
@@ -3499,11 +3499,17 @@ def setup_chat_routes(
# has an exact routed contract. Adding the whole native
# workspace inventory here reintroduced overlapping PDF
# readers and caused the model to abandon the selected
# OCR operation. Exact operations therefore stay exact;
# ordinary native turns retain warm and workspace tools.
# OCR operation. A task-only turn likewise has an exact
# personal manager; core shell/Web tools are not task
# fallbacks. Other turns retain warm and workspace tools.
extra_tools=(
INTERACTIVE_CORE_TOOLS
if _exact_selected_native_chain
frozenset()
if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or (
_active_turn_capabilities in (
frozenset({"transcription"}), frozenset({"ocr"}),
)
and not _selected_tools
)
else INTERACTIVE_CORE_TOOLS | _warm_tools | (
NATIVE_WORKSPACE_TOOLS | (
{"private_browser"} if _local_browser_render_intent else frozenset()
+32 -8
View File
@@ -22416,7 +22416,7 @@ async def stream_agent_loop(
"manage_notes", "manage_calendar", "manage_tasks",
"ask_user", "update_plan",
}
elif _ody_doc_finetune_mode and route_tools is not None:
elif (_ody_doc_finetune_mode or doc_mode) and route_tools is not None:
if _prompt_active_document is not None:
route_tools = {
"edit_document", "update_document", "suggest_document",
@@ -22424,12 +22424,12 @@ async def stream_agent_loop(
}
else:
route_tools = {"create_document", "ask_user", "update_plan"}
elif _ody_notes_finetune_mode and route_tools is not None:
elif (_ody_notes_finetune_mode or notes_mode) and route_tools is not None:
route_tools = {
"manage_notes", "manage_calendar", "manage_tasks",
"ask_user", "update_plan",
}
elif _ody_general_no_tool_mode:
elif _ody_general_no_tool_mode or general_no_tool_mode:
route_tools = set()
else:
route_tools = _route_tui_local_workspace_tools(
@@ -22923,6 +22923,8 @@ async def stream_agent_loop(
# navigation tools. Do not let the general agent floor re-add bash
# after that narrow surface was selected.
and not (_low_signal_turn and workspace)
and not _ody_notes_finetune_mode
and not _ody_general_no_tool_mode
):
from src.turn_contract import CONTRACT_CORE_TOOLS
_core_agent_tools = set(CONTRACT_CORE_TOOLS)
@@ -23170,6 +23172,13 @@ async def stream_agent_loop(
_base_relevant_tools = set(_relevant_tools)
logger.info("[agent-intent] explicit plan request clamped to plan tools")
if _low_signal_turn and not workspace and not _terminal_agent_mode and _relevant_tools is not None:
# Retrieval and the core floor can surface file readers for a vague
# local-project hint even though no project has been selected.
_relevant_tools.difference_update(_DOMAIN_TOOL_MAP["files"])
if _base_relevant_tools is not None:
_base_relevant_tools.difference_update(_DOMAIN_TOOL_MAP["files"])
if _relevant_tools is not None:
logger.info("[agent-intent] selected_tools=%s", sorted(_relevant_tools)[:50])
@@ -24163,6 +24172,7 @@ async def stream_agent_loop(
_failed_read_recovery_sent = False
_failed_read_recovery_instruction_sent = False
_post_effectful_mutation_done = False
_verified_coding_summary_emitted = False
_successful_mutation_signatures: set[tuple[str, str]] = set()
_single_execution_bound = _request_forbids_execution_retry(_last_user)
_execution_tool_attempts: dict[str, int] = {}
@@ -25807,9 +25817,17 @@ async def stream_agent_loop(
and not _approved_result_injected
and not _native_terminal_runtime
and not normalized_external_tool_schemas
# A one-tool shortcut cannot own a causal compound workflow. Let
# the agent consume the complete request-scoped tool surface.
and len(_caller_relevant_tools or ()) <= 1
# The explicit topic-bulk path below owns its search-then-bulk
# sequence. Other multi-tool requests need the agent's full route.
and (
len(_caller_relevant_tools or ()) <= 1
or (
_caller_relevant_tools == {
"mcp__email__search_emails", "mcp__email__bulk_email",
}
and _parse_qwen_explicit_email_topic_bulk_action_request(_last_user)
)
)
and not _request_has_compound_actions(_last_user)
# Sealed safe reads use the central required-operation path so
# execution and canonical rendering have the same owner.
@@ -33827,6 +33845,11 @@ async def stream_agent_loop(
_tui_bash_block_completed
and block.tool_type == "host_shell"
)
and not (
block.tool_type == "host_shell"
and _has_tui_host_bridge
and _post_effectful_mutation_done
)
):
_terminal_summary = _ody_qwen_terminal_tool_summary({
"tool": block.tool_type,
@@ -35261,10 +35284,11 @@ async def stream_agent_loop(
_post_effectful_mutation_done
and _post_edit_verification_completed
and _workspace_mutation_completion_authorized
and _deterministic_terminal_eligible
and (_deterministic_terminal_eligible or _tui_local_execution_turn)
):
if _tui_local_execution_turn or _qwen38_tool_router:
full_response = _tui_verified_coding_summary(tool_events)
_verified_coding_summary_emitted = True
yield f'data: {json.dumps({"type": "final_response", "content": full_response})}\n\n'
elif not full_response.strip() or full_response.strip().startswith("```"):
_verification_output = ""
@@ -36848,7 +36872,7 @@ async def stream_agent_loop(
_response_before_tool_summary = full_response
_action_summary_selected = False
if tool_events and _deterministic_terminal_eligible:
if tool_events and _deterministic_terminal_eligible and not _verified_coding_summary_emitted:
_multi_read_email_summaries = _email_read_summaries_from_tool_events(tool_events)
_multi_attachment_summaries = _email_attachment_summaries_from_tool_events(tool_events)
_bulk_email_state_summary = _email_state_bulk_terminal_summary(tool_events, user_text=_last_user)
+48 -1
View File
@@ -231,6 +231,7 @@ def _wrap_workspace_namespace(
cwd: str,
*,
chdir: str = "/workspace",
interpreter_prefix: str | None = None,
) -> str | None:
"""Run a shell command with the active workspace mounted at /workspace.
@@ -254,8 +255,53 @@ def _wrap_workspace_namespace(
"--dir", "/tmp", "--tmpfs", "/tmp",
"--dev-bind", "/dev", "/dev", "--proc", "/proc",
"--dir", "/workspace", "--bind", cwd, "/workspace",
"--chdir", chdir, "/bin/bash", "-lc", content,
]
# setup-python installs interpreters under /opt, and local CI virtualenvs
# can live under /tmp. Those paths are hidden by the private root/tmpfs.
# Expose only the active interpreter environment, read-only, so Python
# tools keep their installed packages without exposing the host /tmp.
if interpreter_prefix:
prefix = os.path.abspath(interpreter_prefix)
resolved_prefix = os.path.realpath(prefix)
mounted_roots = ("/usr", "/home", "/mnt")
reserved_roots = {
"/", "/tmp", "/var", "/opt", "/etc", "/workspace",
"/root", "/run", "/proc", "/dev", "/sys", *mounted_roots,
}
already_visible = any(
prefix == root or prefix.startswith(root + os.sep)
for root in mounted_roots
)
# A prefix is trusted only when it names a specific interpreter tree.
# In particular, never overlay the private root, tmpfs, or workspace
# with a broad host directory. Reject symlinked prefixes too: bwrap
# would otherwise bind the resolved source at a different destination.
has_environment_layout = (
os.path.isfile(os.path.join(prefix, "pyvenv.cfg"))
or (
os.path.isfile(os.path.join(prefix, "bin", "python"))
and os.path.isdir(os.path.join(
prefix, "lib", f"python{sys.version_info.major}.{sys.version_info.minor}",
))
)
)
if (
not already_visible
and prefix == resolved_prefix
and prefix not in reserved_roots
and len(prefix.split(os.sep)) >= 3
and os.path.isdir(prefix)
and has_environment_layout
):
parents = []
parent = os.path.dirname(prefix)
while parent not in ("/", "/tmp", "/etc", "/workspace", *mounted_roots):
parents.append(parent)
parent = os.path.dirname(parent)
for directory in reversed(parents):
args.extend(("--dir", directory))
args.extend(("--ro-bind", prefix, prefix))
args.extend(("--chdir", chdir, "/bin/bash", "-lc", content))
return shlex.join(args)
@@ -940,6 +986,7 @@ class PythonTool:
python_command,
agent_cwd(),
chdir="/workspace",
interpreter_prefix=sys.prefix,
)
if needs_virtual_namespace
else None
+13 -10
View File
@@ -784,6 +784,15 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None:
# Content words such as "reviews", "which", "highlights", or
# "final" must not become a public-Web lookup operation.
return None
if re.fullmatch(
_REQUEST_PREFIX + r"(?:which\s+search\s+(?:backend|provider)\s+am\s+i\s+on"
r"(?:\s+right\s+now)?|what\s+(?:default\s+)?time\s+filter\s+is\s+"
r"my\s+search\s+set\s+to(?:\s+by\s+default)?|show\s+me\s+the\s+whole\s+"
r"search\s+(?:settings?\s+)?group)[?!.]*",
text,
re.I,
):
return frozenset({"manage_settings"})
if (
re.search(r"\b(?:look\s*up|search|find)\b", text, re.I)
and re.search(
@@ -797,6 +806,7 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None:
text,
re.I,
)
and not re.search(r"\b(?:inbox|emails?|mails?|calendar|meetings?|my\s+notes?)\b", text, re.I)
):
# Current lookups need discovery before navigation. Letting the model
# begin on an arbitrary browser page can ground an answer in stale or
@@ -811,7 +821,7 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None:
r"compare|pros?|cons?|opinions?|thoughts?|about)\b",
text,
re.I,
):
) and not re.search(r"\b(?:inbox|emails?|mails?|calendar|meetings?|my\s+notes?)\b", text, re.I):
# Product/service review requests are current public-web lookups even
# when the user does not say "search". Route them to web_search before
# the model sees a schema; otherwise a no-tool contract invites raw
@@ -973,15 +983,6 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None:
re.I,
):
return frozenset({"web_search"})
if re.fullmatch(
_REQUEST_PREFIX + r"(?:which\s+search\s+(?:backend|provider)\s+am\s+i\s+on"
r"(?:\s+right\s+now)?|what\s+(?:default\s+)?time\s+filter\s+is\s+"
r"my\s+search\s+set\s+to(?:\s+by\s+default)?|show\s+me\s+the\s+whole\s+"
r"search\s+(?:settings?\s+)?group)[?!.]*",
text,
re.I,
):
return frozenset({"manage_settings"})
if re.fullmatch(
_REQUEST_PREFIX + r"(?:is\s+there\s+)?anything\s+new\s+(?:in|on|about)\s+"
r"[^?!.]{2,160}\b(?:today|this\s+(?:week|month|year)|recently)[?!.]*",
@@ -4320,6 +4321,8 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum
established_family = immediately_established_family(text, history)
if established_family and not newly_named_families:
return frozenset({established_family})
if selected_tools_for_request(raw_text) == frozenset({"manage_settings"}):
return frozenset({"cookbook_admin"})
concrete_urls = re.findall(r"\bhttps?://[^\s<>\"']+", raw_text, re.I)
workspace_media = re.search(
r"(?:file://)?/workspace/[^\s`\"']+\."
+10
View File
@@ -7521,6 +7521,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
<button type="button" class="md-toolbar-rich-only" data-md="subscript" title="Subscript" style="display:none"><span class="rich-script-icon">x<sub>2</sub></span></button>
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
<button type="button" class="md-dd-toggle md-toolbar-edit-only" data-dd="heading" title="Heading (Ctrl+Alt+1-6)"><b>H</b><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<button type="button" class="md-dd-toggle md-toolbar-rich-only" data-dd="font" title="Font family" aria-label="Font family" style="display:none"><span>Aa</span><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<span id="md-toolbar-sep-after-heading" class="md-toolbar-sep md-toolbar-manual-sep md-toolbar-edit-only" aria-hidden="true"></span>
<button type="button" class="md-dd-toggle md-toolbar-edit-only" data-dd="list" title="Bulleted list"><svg class="rich-list-bullet-icon" width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="4" cy="6" r="1.5"/><circle cx="4" cy="12" r="1.5"/><circle cx="4" cy="18" r="1.5"/><path d="M9 5h12v2H9zM9 11h12v2H9zM9 17h12v2H9z"/></svg><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
@@ -7717,6 +7718,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
_renderDocumentStats();
statsPopover.hidden = false;
statsPopover.style.zIndex = String(topPortalZ());
// The editor pane clips overflow, so a footer-anchored absolute
// popover can disappear underneath the document. Float it against
// the viewport and place it above the stats button.
@@ -10693,6 +10695,13 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
['h5', 'Heading 5', 'H5'],
['h6', 'Heading 6', 'H6'],
],
font: [
['fontname:Arial', 'Arial', 'Aa'],
['fontname:Georgia', 'Georgia', 'Gg'],
['fontname:Times New Roman', 'Times New Roman', 'Tt'],
['fontname:Verdana', 'Verdana', 'Vv'],
['fontname:Courier New', 'Courier New', 'Cc'],
],
code: [['code', 'Inline code', '`'], ['codeblock', 'Code block', '```']],
list: [
['ul', 'Bullet list', '•'],
@@ -11112,6 +11121,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
'#md-toolbar-sep-after-ai-reply',
'#doc-ai-writing-btn',
'[data-dd="heading"]',
'[data-dd="font"]',
'#md-toolbar-sep-after-heading',
'[data-dd="textsize"]',
],
+1 -1
View File
@@ -38,7 +38,7 @@ export default function createResearchSynapse(container, opts = {}) {
<span class="rs-sep">·</span>
<span class="rs-timer">00:00</span>
</div>
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Live research map">
<g class="rs-guide" aria-hidden="true">
<ellipse cx="${cx}" cy="${cy}" rx="116" ry="72"></ellipse>
<ellipse cx="${cx}" cy="${cy}" rx="58" ry="36"></ellipse>
+1 -2
View File
@@ -9431,7 +9431,7 @@ pre { background: var(--code-bg, var(--hl-bg, #282c34)) !important; }
}
.compare-score-label {
position: relative;
top: 0;
top: -2px;
display: inline-block;
}
.compare-vote-btn:hover { border-color: var(--red); background: color-mix(in srgb, var(--red) 11%, transparent); }
@@ -39084,7 +39084,6 @@ body.doc-find-active mark.doc-find-mark.current {
position: fixed;
right: auto;
bottom: auto;
z-index: 100000;
width: 190px;
padding: 8px 10px;
border: 1px solid var(--border);
+2
View File
@@ -143,6 +143,8 @@ const PANEL_PRECACHE = [
'/static/js/editor/document-geometry.js',
'/static/js/editor/export-dialog.js',
'/static/js/editor/selection-mask.js',
'/static/js/editor/selection-modifiers.js',
'/static/js/editor/tool-shortcuts.js',
'/static/js/editor/filters/blur.js',
'/static/js/editor/filters/edge-feather.js',
'/static/js/editor/fx/adj-popup.js',
+48
View File
@@ -4,6 +4,7 @@ import os
import types
import importlib.util
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -93,3 +94,50 @@ def pytest_collection_modifyitems(config, items):
path = getattr(item, "path", None) or item.fspath
for marker_name in markers_for_path(path):
item.add_marker(getattr(pytest.mark, marker_name))
@pytest.fixture(scope="session", autouse=True)
def _serve_test_static():
"""Ensure static assets are available on loopback port 7011 for browser integration tests."""
import socket
import threading
import http.server
import socketserver
from pathlib import Path
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
is_bound = (sock.connect_ex(("127.0.0.1", 7011)) == 0)
finally:
sock.close()
if is_bound:
raise RuntimeError("port 7011 is already in use; browser tests require this worktree's static server")
root_dir = Path(__file__).resolve().parent.parent
class _Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(root_dir), **kwargs)
def log_message(self, format, *args):
pass
def guess_type(self, path):
if path.endswith(".js") or path.endswith(".mjs"):
return "application/javascript"
if path.endswith(".css"):
return "text/css"
return super().guess_type(path)
class _Server(socketserver.TCPServer):
allow_reuse_address = True
server = _Server(("127.0.0.1", 7011), _Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield
finally:
server.shutdown()
server.server_close()
@@ -847,9 +847,10 @@ function resolveImport(specifier, parent) {
throw new Error(`Unexpected non-relative import: ${specifier}`);
}
const cleanSpecifier = specifier.split('?')[0].split('#')[0];
return path.resolve(
path.dirname(parent),
specifier,
cleanSpecifier,
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export async function loadMarkdown() {
globalThis.MutationObserver = class { observe() {} };
let src = fs.readFileSync(path.join(REPO, 'static/js/markdown.js'), 'utf8');
src = src.replace(/import uiModule from ['"]\.\/ui\.js['"];/, '');
src = src.replace(/import uiModule from ['"]\.\/ui\.js(?:[?#][^'"]*)?['"];?/, '');
src = src.replace(
/import \{ splitTableRow \} from ['"]\.\/markdown\/tableRow\.js['"];/,
() => `function splitTableRow(row){return (row||'').replace(/^\\s*\\|/,'').replace(/\\|\\s*$/,'').split('|').map((c)=>c.trim());}`,
+2 -2
View File
@@ -161,13 +161,13 @@ def test_email_settings_show_away_account_and_compact_display_controls() -> None
assert "enabled ? 'Show' : 'Hide'" in source
assert "email-settings-inline-link" in source
assert "email-auto-reply-exclude" not in source
assert "_emailWritingStyleHtml(writingStyle) + _emailDisplaySettingsHtml()" in source
assert "_emailWritingStyleHtml(writingStyle) + _emailDisplaySettingsHtml(cfg)" in source
assert ".email-settings-status.is-success" in style
assert "var(--color-success, #4caf50)" in style
assert ".email-style-settings-extract svg" in style
assert "export async function mountEmailSettings(host)" in source
assert "_openGlobalEmailSettings('show-tags')" in source
assert source.count('class="admin-card email-settings-section') == 4
assert source.count('class="admin-card email-settings-section') == 5
assert 'id="settings-email-default-card"' in (ROOT / "static/index.html").read_text(encoding="utf-8")
assert "multipleAccounts" in source
+2 -3
View File
@@ -617,8 +617,7 @@ def test_finish_nudge_does_not_accept_unfinished_correction_promise(monkeypatch)
monkeypatch,
[
'```write_file\n/workspace/output.html\n<body>draft</body>\n```',
'<tool_call><invoke name="private_browser"><parameter name="action">open</parameter><parameter name="url">file:///workspace/output.html</parameter></invoke></tool_call>',
"The preview revealed a defect. I should complete output.html by adding labels.",
"The draft has a defect. I should complete output.html by adding labels.",
'```write_file\n/workspace/output.html\n<body>corrected</body>\n```',
"Done. Corrected and checked output.html.",
],
@@ -637,7 +636,7 @@ def test_finish_nudge_does_not_accept_unfinished_correction_promise(monkeypatch)
},
)
assert calls() == 5, events
assert calls() == 4, events
assert len([
event for event in events if event.get("type") == "artifact_finish_nudge"
]) == 1
+3 -1
View File
@@ -56,6 +56,8 @@ class _FakeSkillsManager:
"pitfalls": ["do not skip verification"],
"requires_toolsets": ["grep"],
"status": "published",
"audit_verdict": "pass",
"confidence": 1.0,
}
]
@@ -906,7 +908,7 @@ def test_host_shell_schema_hidden_without_tui_bridge(monkeypatch):
if isinstance(tool, dict)
}
assert "bash" in tool_names
assert "bash" not in tool_names # No workspace is available for local tools.
assert "host_shell" not in tool_names
+1 -1
View File
@@ -8,7 +8,7 @@ THEME_JS = (ROOT / "static/js/theme.js").read_text(encoding="utf-8")
def test_five_distinct_builtin_themes_are_available() -> None:
expected = {
"eclipse": "constellations",
"eclipse": "starfield-depth",
"porcelain": "dots",
"arcade": "synapse",
"blueprint": "dots",
+86 -14
View File
@@ -9,6 +9,7 @@ Fix: (1) Read from JSON body as fallback.
"""
import ast
import json
from pathlib import Path
import pytest
@@ -35,6 +36,7 @@ from src.tool_policy import (
web_intent_may_enable_for_turn,
web_search_enabled_for_turn,
)
from tests.test_foreground_model_routing import _RouteRequest, _chat_stream_endpoint
_CHAT_ROUTES = Path(__file__).resolve().parent.parent / "routes" / "chat_routes.py"
@@ -283,24 +285,94 @@ def test_contextual_browser_followup_recognizes_current_page_inspection():
assert not _is_contextual_browser_followup("Show my notes.", session)
def test_clean_browser_filter_preserves_native_pdf_extraction_contract():
source = _CHAT_ROUTES.read_text()
assert "{'private_browser'} | NATIVE_WORKSPACE_TOOLS" in source
async def _clean_route_contract(monkeypatch, message, *, history=(), native=False):
from routes import chat_routes
from src import tool_security
with monkeypatch.context() as route_patch:
endpoint = _chat_stream_endpoint(
route_patch, "agent", {},
session_model="odysseus-qwen3.5-tools-pre-heretic",
session_history=history,
)
route_patch.setattr(
chat_routes, "coerce_message_and_session",
lambda *args, **kwargs: (message, "session-1"),
)
route_patch.setattr(
tool_security, "owner_is_admin_or_single_user", lambda owner: True,
)
observed = []
async def capture_agent(*args, **kwargs):
observed.append(kwargs["turn_contract"])
yield 'data: {"delta":"Contract constructed."}\n\n'
yield "data: [DONE]\n\n"
route_patch.setattr(chat_routes, "stream_agent_loop", capture_agent)
request = _RouteRequest("agent")
request._form.update({"message": message, "compare_mode": "false"})
if native:
request._form.update({
"cwd": "/tmp/native-workspace",
"workspace": "/tmp/native-workspace",
"client_runtime_context": json.dumps({
"surface": "odysseus-native", "terminal_agent": True,
"unattended_mode": True,
"input_files": ["/workspace/paper.pdf"],
}),
})
response = await endpoint(request)
async for _ in response.body_iterator:
pass
assert len(observed) == 1
return observed[0]
def test_clean_preview_only_offers_browser_for_explicit_or_typed_warm_turns():
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "_has_recent_private_browser_success(sess)" in source
assert "if _explicit_browser_intent:" in source
assert "tool_family(s['function']['name']) != 'search_browser'" in source
assert "elif not _clean_v3_private_browser_warm and not (" in source
assert "_native_workspace_contract and _local_browser_render_intent" in source
@pytest.mark.asyncio
async def test_clean_browser_filter_preserves_native_pdf_extraction_contract(monkeypatch):
contract = await _clean_route_contract(
monkeypatch,
"Extract Table 2 from /workspace/paper.pdf using pdf_extract.",
native=True,
)
assert contract.capabilities == {"shell_files"}
assert contract.permits("pdf_extract")
assert "private_browser" not in contract.offered
assert not {"manage_tasks", "search_emails", "send_email"} & contract.offered
def test_explicit_web_fetch_is_not_erased_by_generic_browser_intent():
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "and not set(_selected_tools or ()).intersection(" in source
assert "{'web_search', 'web_fetch'}" in source
@pytest.mark.asyncio
async def test_clean_preview_only_offers_browser_for_explicit_or_typed_warm_turns(monkeypatch):
message = "Summarize the status."
no_history = await _clean_route_contract(monkeypatch, message)
failed_history = [{"role": "assistant", "metadata": {"tool_events": [{
"tool": "private_browser", "exit_code": 1, "error": True,
}]}}]
failed = await _clean_route_contract(monkeypatch, message, history=failed_history)
successful_history = [{"role": "assistant", "metadata": {"tool_events": [{
"tool": "private_browser", "exit_code": 0, "error": False,
}]}}]
warm = await _clean_route_contract(monkeypatch, message, history=successful_history)
explicit = await _clean_route_contract(
monkeypatch, "Open https://example.com with the private browser",
)
assert "private_browser" not in no_history.offered
assert "private_browser" not in failed.offered
assert warm.permits("private_browser")
assert explicit.permits("private_browser")
@pytest.mark.asyncio
async def test_explicit_web_fetch_is_not_erased_by_generic_browser_intent(monkeypatch):
contract = await _clean_route_contract(
monkeypatch,
"Use web_fetch to read https://example.com/report in the private browser.",
)
assert contract.capabilities == {"search_browser"}
assert contract.required == {"web_fetch"}
assert contract.permits("web_fetch")
assert not {"manage_tasks", "search_emails", "send_email"} & contract.offered
def test_web_followup_grammar_covers_article_detail_questions():
+3 -2
View File
@@ -25,8 +25,9 @@ def test_measured_ttft_is_shown_in_message_stats():
def test_compact_footer_and_details_show_real_performance_counters():
assert "`${Number(tps).toFixed(2)} tok/s`" in RENDERER
assert "`${Number(ttft).toFixed(3)}s TTFT`" in RENDERER
assert "`${Number(injectedTokens).toLocaleString()} in`" in RENDERER
assert "const visibleTtft = metrics.client_ttft ?? metrics.time_to_first_token" in RENDERER
assert "${Number(visibleTtft).toFixed(3)}s" in RENDERER
assert "${Number(injectedTokens).toLocaleString()}" in RENDERER
assert '<span class="ctx-label">Input</span>' in RENDERER
assert '<span class="ctx-label">Injected</span>' in RENDERER
assert 'all rounds' not in RENDERER
@@ -87,7 +87,7 @@ async def test_malformed_text_artifact_write_uses_one_bounded_raw_body_handoff(m
events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk]
assert len(requests) == 2
assert 'tools' not in requests[1]
assert requests[1]['max_tokens'] == 4096
assert requests[1]['max_tokens'] == 8192
assert len(executed) == 1
assert executed[0].tool_type == 'write_file'
assert executed[0].content == (
+4 -4
View File
@@ -6330,7 +6330,7 @@ async def test_native_stream_terminates_after_calling_a_permanently_suppressed_t
{"choices": [{"delta": {"tool_calls": [{"index": 0, "id": f"inspect-{index}", "function": {
"name": "inspect_media", "arguments": arguments,
}}]}}]}
for index in range(1, 5)
for index in range(1, 4)
] + [{"choices": [{"delta": {"content": "Final answer from existing evidence."}}]}])
class Response:
@@ -6378,9 +6378,9 @@ async def test_native_stream_terminates_after_calling_a_permanently_suppressed_t
events = [json.loads(chunk[6:]) for chunk in raw if "[DONE]" not in chunk]
assert len(executions) == 1
assert len(requests) == 5
assert 'tools' not in requests[4]
assert 'best concise final answer' in requests[4]['messages'][-1]['content'].lower()
assert len(requests) == 4
assert 'tools' not in requests[3]
assert 'best concise final answer' in requests[3]['messages'][-1]['content'].lower()
final = [event for event in events if event.get("type") == "final_response"]
assert final == []
metrics = next(event['data'] for event in events if event.get('type') == 'metrics')
+2 -1
View File
@@ -52,7 +52,8 @@ def test_native_workspace_allows_scoped_write_and_python_only_when_enabled():
python = {"code": "1 + 1"}
assert not preview_call_allowed("write_file", write, "write the output")
assert not preview_call_allowed(
assert not preview_call_allowed("python", python, "analyze the file")
assert preview_call_allowed(
"python", python, "analyze the file", allow_execute_code=True
)
assert preview_call_allowed(
+4 -4
View File
@@ -200,7 +200,7 @@ def test_compare_panes_surface_compact_result_summary():
assert "font-variant-numeric: tabular-nums;" in styles
def test_compare_selector_surfaces_endpoint_metadata_and_blocks_duplicates():
def test_compare_selector_surfaces_duplicate_warning_without_blocking_start():
root = Path(__file__).resolve().parents[1]
selector = (root / "static/js/compare/selector.js").read_text(encoding="utf-8")
styles = (root / "static/style.css").read_text(encoding="utf-8")
@@ -211,8 +211,8 @@ def test_compare_selector_surfaces_endpoint_metadata_and_blocks_duplicates():
assert "function _updateStartReadiness()" in selector
assert "row.classList.add('cmp-model-row-duplicate');" in selector
assert "Duplicate selection" in selector
assert "startBtn.disabled = blocked;" in selector
assert "Remove duplicate selections before starting compare" in selector
assert "startBtn.disabled = false;" in selector
assert "Duplicate selections will run as separate panes" in selector
assert "if (selections.length > 1)" in selector
assert selector.count("renderModelRows();") >= 12
@@ -227,7 +227,7 @@ def test_compare_selector_surfaces_endpoint_metadata_and_blocks_duplicates():
assert "order: 2;" in rm_block
assert "margin-left: auto;" in rm_block
assert "align-self: center;" in rm_block
assert "top: -3px;" in rm_block
assert "top: -2px;" in rm_block
def test_unsaved_compare_helper_sessions_do_not_render_in_sidebar():
+1 -1
View File
@@ -12,7 +12,7 @@ def test_mobile_compare_mounts_accessible_tabs_without_removing_panes():
index = _read("static/js/compare/index.js")
panes = _read("static/js/compare/panes.js")
assert "mountMobilePaneTabs(container, grid)" in index
assert "mountMobilePaneTabs(container, grid, (anchor) => _addPane(anchor))" in index
assert "role', 'tablist'" in panes
assert "role', 'tab'" in panes
assert "role', 'tabpanel'" in panes
@@ -36,7 +36,7 @@ def test_omitted_memory_survives_only_explicit_drop(monkeypatch):
monkeypatch.setattr(src.memory, "MemoryManager", _FakeMM)
monkeypatch.setattr(
src.task_endpoint, "resolve_task_candidates",
lambda owner=None: [("http://x/v1", "model", {})],
lambda owner=None, **kwargs: [("http://x/v1", "model", {})],
)
async def fake_llm(_candidates, **kwargs):
@@ -58,7 +58,7 @@ def _extract_thinking_blocks(text: str) -> dict:
let source = fs.readFileSync('./static/js/markdown.js', 'utf8');
source = source.replace(
/import uiModule from ['"]\.\/ui\.js['"];/,
/import uiModule from ['"]\.\/ui\.js(?:[?#][^'"]*)?['"];?/,
''
);
source = source.replace(
+2 -1
View File
@@ -83,7 +83,8 @@ def test_pages_site_owns_its_entrypoint_and_media():
assert REPO / "website/index.html" in website_files
assert REPO / "docs/index.html" not in docs_files
assert not [p for p in docs_files if p.suffix.lower() in VIDEO_EXTS | {".md"}]
assert not [p for p in docs_files if p.suffix.lower() in VIDEO_EXTS]
assert not [p for p in docs_files if p.name in PUBLIC_GUIDES]
website_paths = {p.relative_to(REPO / "website").as_posix() for p in website_files}
assert PUBLIC_GUIDES <= website_paths
@@ -18,7 +18,7 @@ def test_expanded_document_card_has_export_beside_clone():
def test_expanded_export_reuses_download_function_without_proxy_click():
assert "const exportDocumentFile = async () =>" in DOC_LIBRARY_JS
assert "const exportDocumentFile = async (format = 'original') =>" in DOC_LIBRARY_JS
assert "await exportDocumentFile();" in DOC_LIBRARY_JS
assert "exportItem.click();" not in DOC_LIBRARY_JS
assert "exportItem.type = 'button';" in DOC_LIBRARY_JS
+6 -4
View File
@@ -10,24 +10,26 @@ SOURCE = (ROOT / "static/js/documentLibrary.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
def test_mobile_footer_exposes_open_and_more_only():
def test_mobile_footer_exposes_delete_open_and_more():
assert "doclib-expanded-open-btn" in SOURCE
assert "doclib-expanded-mobile-more" in SOURCE
assert "label: 'Open in new chat'" in SOURCE
assert "'Open in original' : 'Open document'" in SOURCE
assert "label: 'Export file'" in SOURCE
assert "label: 'Export file ›'" in SOURCE
assert "label: 'Original format'" in SOURCE
assert "label: 'Markdown (.md)'" in SOURCE
assert "'Restore document' : 'Archive document'" in SOURCE
assert "label: 'Delete document'" in SOURCE
mobile_css = STYLE.split("The Documents preview footer only exposes Open and More", 1)[1]
mobile_css = STYLE.split("On phones, keep Delete explicit", 1)[1]
mobile_css = mobile_css.split("/* Chat top bar", 1)[0]
for hidden_action in (
".doclib-expanded-delete-btn",
".doclib-expanded-archive-btn",
".doclib-expanded-clone-btn",
".doclib-expanded-export-btn",
):
assert hidden_action in mobile_css
assert ".doclib-expanded-delete-btn {\n display: inline-flex !important" in mobile_css
assert ".doclib-expanded-mobile-more" in mobile_css
assert "display: inline-flex" in mobile_css
assert "box-sizing: border-box" in mobile_css
@@ -1,6 +1,7 @@
"""Numeric font sizes and the shared app color picker in Rich Text."""
import json
import re
import subprocess
from pathlib import Path
@@ -11,7 +12,7 @@ STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
def test_font_and_color_controls_use_shared_components():
assert "import { attachColorPicker } from './colorPicker.js?v=20260831richtexttools91';" in DOC_JS
assert re.search(r"import \{ attachColorPicker \} from './colorPicker\.js\?v=[A-Za-z0-9_-]+';", DOC_JS)
assert 'data-dd="textsize" title="Font size" aria-label="Font size"' in DOC_JS
for size, pixels in {1: 10, 2: 13, 3: 16, 4: 18, 5: 24, 6: 32, 7: 48}.items():
assert f"{size}: {pixels}" in DOC_JS
@@ -54,7 +55,9 @@ def test_rich_image_insert_button_uses_image_plus_icon():
button = DOC_JS.split('id="md-toolbar-attach-btn"', 1)[1].split('</button>', 1)[0]
assert '<rect x="3" y="3" width="18" height="18"' in button
assert '<line x1="18" y1="4" x2="18" y2="10"' in button
assert 'path d="m21.44 11.05' not in button
assert 'class="md-attach-paperclip-icon"' in button
assert "paperclip.style.display = isEmail ? '' : 'none'" in DOC_JS
assert "imageIcon.style.display = isEmail ? 'none' : ''" in DOC_JS
def test_selection_clear_formatting_only_shows_for_formatted_ranges():
@@ -100,13 +103,14 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile():
current_content: '<p>Font target</p><p>Color target</p><p>Highlight target</p>',
version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 450));
}, suffix);
await page.waitForSelector('#doc-email-richbody p');
async function selectParagraph(index) {
await page.evaluate(index => {
const rich = document.querySelector('#doc-email-richbody');
const paragraph = rich.querySelectorAll('p')[index];
if (!paragraph) throw new Error(`Missing paragraph ${index}: ${rich.innerHTML}`);
rich.focus();
const range = document.createRange();
range.selectNodeContents(paragraph);
@@ -64,7 +64,8 @@ def test_rich_document_shortcuts_work_at_desktop_and_mobile_widths():
await selectText('Align target');
await page.keyboard.press('Control+Shift+e');
const center = await page.locator('#doc-email-richbody').evaluate(root => root.children[1].style.textAlign);
await page.keyboard.press('Control+Shift+r');
await page.locator('[data-dd="align"]').click();
await page.locator('#doc-md-dd-menu .doc-overflow-item').filter({ hasText: 'Align right' }).click();
const right = await page.locator('#doc-email-richbody').evaluate(root => root.children[1].style.textAlign);
await page.keyboard.press('Control+Shift+j');
const justify = await page.locator('#doc-email-richbody').evaluate(root => root.children[1].style.textAlign);
+8 -3
View File
@@ -52,7 +52,9 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
current_content: '<p>Opening paragraph</p><p><br></p>',
version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 450));
});
await page.waitForFunction(() => document.querySelectorAll('#doc-email-richbody p').length >= 2);
await page.evaluate(() => {
const block = document.querySelector('#doc-email-richbody p:last-child');
const range = document.createRange();
range.selectNodeContents(block);
@@ -111,6 +113,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
});
await page.keyboard.type('/');
await page.waitForSelector('#doc-rich-slash-menu');
await page.mouse.move(1, 1);
await page.keyboard.press('End');
const mobile = await page.locator('#doc-rich-slash-menu').evaluate(el => {
const rect = el.getBoundingClientRect();
@@ -131,6 +134,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
};
});
await page.keyboard.press('Home');
const homeOptions = await page.locator('.doc-rich-slash-label').allTextContents();
const homeLabel = await page.locator('.doc-rich-slash-item.is-active .doc-rich-slash-label').textContent();
await page.keyboard.press('Escape');
const escaped = await page.locator('#doc-rich-slash-menu').count() === 0;
@@ -142,7 +146,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
popup: el.hasAttribute('aria-haspopup'),
}));
console.log(JSON.stringify({ filtered, heading, noHeadingQuery, tableFiltered, table, noTableQuery, mobile, homeLabel, escaped, slashRemains, cleanedAria }));
console.log(JSON.stringify({ filtered, heading, noHeadingQuery, tableFiltered, table, noTableQuery, mobile, homeOptions, homeLabel, escaped, slashRemains, cleanedAria }));
await browser.close();
"""
result = subprocess.run(
@@ -169,7 +173,8 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
assert mobile["activeLabel"] == "Image"
assert mobile["activeDescendant"] == mobile["activeId"]
assert mobile["scrollTop"] > 0
assert data["homeLabel"] == "Text"
assert data["homeOptions"][0] == "Text"
assert data["homeLabel"] == data["homeOptions"][0], data
assert data["escaped"] is True
assert data["slashRemains"] is True
assert data["cleanedAria"] == {
+7 -3
View File
@@ -66,8 +66,8 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens():
id: 'toggle-doc', title: 'Toggle menu', language: 'richtext',
current_content: '<p>Toggle target</p>', version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 450));
});
await page.waitForSelector('#doc-email-richbody p');
const toggle = page.locator('button[data-dd="font"]');
await toggle.click();
@@ -138,7 +138,9 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus():
current_content: '<p>Paragraph</p>',
version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 450));
});
await page.waitForSelector('#doc-email-richbody p');
await page.evaluate(() => {
const paragraph = document.querySelector('#doc-email-richbody p');
const range = document.createRange();
range.selectNodeContents(paragraph);
@@ -251,7 +253,9 @@ def test_rich_toolbar_menus_track_live_formatting_values():
current_content: '<p>Stateful text</p>',
version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 450));
});
await page.waitForSelector('#doc-email-richbody p');
await page.evaluate(() => {
const paragraph = document.querySelector('#doc-email-richbody p');
const range = document.createRange();
range.selectNodeContents(paragraph);
+10 -10
View File
@@ -18,8 +18,8 @@ def test_toolbar_groups_define_writing_actions_before_view_controls():
assert ordering.index("name: 'inline-basic'") < ordering.index("name: 'inline-color'")
assert ordering.index("name: 'inline-color'") < ordering.index("name: 'alignment'")
assert ordering.index("name: 'alignment'") < ordering.index("name: 'spacing'")
assert ordering.index("name: 'spacing'") < ordering.index("name: 'link'")
assert ordering.index("name: 'link'") < ordering.index("name: 'paragraph'")
assert ordering.index("'[data-md=\"link\"]'") < ordering.index("name: 'alignment'")
assert ordering.index("name: 'spacing'") < ordering.index("name: 'paragraph'")
assert ordering.index("name: 'paragraph'") < ordering.index("name: 'insert'")
assert ordering.index("name: 'insert'") < ordering.index("name: 'document'")
assert ordering.index("name: 'document'") < ordering.index("name: 'view'")
@@ -49,8 +49,8 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
current_content: '<p>Writing tools</p>',
version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 450));
}, suffix);
await page.waitForSelector('#doc-email-richbody p');
const state = await page.evaluate(() => {
const toolbar = document.querySelector('#md-toolbar-items');
@@ -58,7 +58,8 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
const key = item => item.dataset.dd || item.dataset.md || item.id;
const visible = controls.filter(item => {
const style = getComputedStyle(item);
return style.display !== 'none' && style.visibility !== 'hidden';
return !item.classList.contains('md-toolbar-sep')
&& style.display !== 'none' && style.visibility !== 'hidden';
});
return {
all: controls.map(item => [item.dataset.toolbarGroup, key(item)]),
@@ -90,7 +91,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
expected_groups = [
"display-size", "type", "inline-basic", "inline-color", "alignment", "spacing",
"link", "paragraph", "insert", "inline-rich", "document", "view",
"paragraph", "insert", "inline-rich", "document", "view",
]
expected_separators = [
"display-size-type",
@@ -98,8 +99,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
"inline-basic-inline-color",
"inline-color-alignment",
"alignment-spacing",
"spacing-link",
"link-paragraph",
"spacing-paragraph",
"paragraph-insert",
"insert-inline-rich",
"inline-rich-document",
@@ -110,7 +110,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
assert list(dict.fromkeys(groups)) == expected_groups
assert state["separators"] == expected_separators
assert state["visible"][:6] == [
"doc-fontsize-btn",
"doc-ai-writing-btn",
"heading",
"font",
"textsize",
@@ -118,11 +118,11 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
"italic",
]
assert state["visible"].index("link") < state["visible"].index("list")
assert state["visible"].index("list") < state["visible"].index("md-toolbar-attach-btn")
assert state["visible"].index("md-toolbar-attach-btn") < state["visible"].index("list")
assert state["visible"].index("md-toolbar-attach-btn") < state["visible"].index("doc-find-toolbar-btn")
assert state["visible"].index("subscript") > state["visible"].index("md-toolbar-attach-btn")
if "doc-outline-toolbar-btn" in state["visible"]:
assert state["visible"].index("doc-fontsize-btn") < state["visible"].index("doc-outline-toolbar-btn")
assert state["visible"].index("doc-ai-writing-btn") < state["visible"].index("doc-outline-toolbar-btn")
assert state["pageOverflow"] == 0
assert data["mobile"]["toolbarOverflow"] is True
+3 -1
View File
@@ -91,13 +91,15 @@ def test_apply_pan_updates_state_canvas_overlay_and_cursor_classes():
def test_hand_navigation_is_wired_to_toolbar_keyboard_and_offline_graph():
toolbar = (ROOT / "static/js/editor/build/toolbar.js").read_text(encoding="utf-8")
shortcuts = (ROOT / "static/js/editor/tool-shortcuts.js").read_text(encoding="utf-8")
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
events = (ROOT / "static/js/editor/canvas-events.js").read_text(encoding="utf-8")
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
service_worker = (ROOT / "static/sw.js").read_text(encoding="utf-8")
assert "{ id: 'hand', label: 'Hand'" in toolbar
assert "key: 'H'" in toolbar
assert "t.key = TOOL_SHORTCUTS[t.id]" in toolbar
assert "hand: 'H'" in shortcuts
assert "e.code === 'Space'" in keyboard
assert "setTemporaryPan?.(true)" in keyboard
assert "isDirectPanIntent(state.tool, state.spacePanActive" in events
+2 -2
View File
@@ -20,8 +20,8 @@ def test_hover_thumbnail_uses_rendered_layer_output():
def test_inline_layer_rows_share_the_common_thumbnail_renderer():
panel = (ROOT / "static/js/editor/layer-panel.js").read_text()
assert "const previewCanvas = renderLayer?.(layer) || layer.canvas;" in panel
assert "const thumb = createInlineThumbnail(previewCanvas, `${layer.name} preview`);" in panel
assert "const thumb = createInlineThumbnail(() => renderLayer?.(layer) || layer.canvas, `${layer.name} preview`);" in panel
assert "thumb._refreshPreview = draw;" in panel
assert "thumbCtx.fillStyle = ((x / tile + y / tile) & 1)" not in panel
+3 -1
View File
@@ -119,6 +119,7 @@ def test_rasterize_text_layer_clears_retained_metadata_only_for_text():
def test_text_tool_is_retained_across_editor_subsystems():
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
toolbar = (ROOT / "static/js/editor/build/toolbar.js").read_text(encoding="utf-8")
shortcuts = (ROOT / "static/js/editor/tool-shortcuts.js").read_text(encoding="utf-8")
controls = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
codec = (ROOT / "static/js/editor/document-codec.js").read_text(encoding="utf-8")
geometry = (ROOT / "static/js/editor/document-geometry.js").read_text(encoding="utf-8")
@@ -127,7 +128,8 @@ def test_text_tool_is_retained_across_editor_subsystems():
merge = (ROOT / "static/js/editor/wire-merge-buttons.js").read_text(encoding="utf-8")
assert "{ id: 'text', label: 'Text'" in toolbar
assert "key: 'T'" in toolbar
assert "t.key = TOOL_SHORTCUTS[t.id]" in toolbar
assert "text: 'T'" in shortcuts
assert 'id="ge-text-section"' in controls
assert "if (state.tool === 'text') return _placeText(e);" in editor
assert "kind: l.kind || 'raster'" in editor
+9 -9
View File
@@ -32,15 +32,15 @@ def test_stacked_labels_share_one_size_and_position_rule():
assert "top: 2px;" in STYLE
def test_narrow_topbar_reflows_essential_actions_instead_of_clipping_them():
assert "ge-topbar-overflow" in OVERFLOW
assert "topbar.classList.add('ge-topbar-overflow')" in OVERFLOW
assert "(max-width: 700px)" in OVERFLOW
assert ".ge-topbar.ge-topbar-overflow" in STYLE
assert "flex: 1 0 100%;" in STYLE
right_start = STYLE.index(".ge-topbar-overflow .ge-topbar-right")
overflow_right = STYLE[right_start:STYLE.index("}", right_start)]
assert "justify-content: flex-start;" in overflow_right
def test_narrow_topbar_scrolls_essential_actions_and_hides_ai_group_first():
assert "if (topbar.scrollWidth > topbar.clientWidth)" in OVERFLOW
assert "aiGroup.forEach(el => { el.style.display = 'none'; });" in OVERFLOW
assert "new ResizeObserver(() => syncOverflow())" in OVERFLOW
mobile = STYLE[STYLE.index("/* Editor topbar — scrolls horizontally") :]
topbar = mobile[mobile.index(".ge-topbar {") : mobile.index("}", mobile.index(".ge-topbar {"))]
assert "overflow-x: auto;" in topbar
assert "flex-wrap: nowrap;" in topbar
assert "justify-content: flex-start;" in topbar
def test_desktop_tool_rail_keeps_long_tool_names_readable():
+5 -3
View File
@@ -160,8 +160,10 @@ def test_attachment_cache_backfill_preserves_message_id(tmp_path, monkeypatch):
def test_single_email_tag_has_no_more_control():
library = open("static/js/emailLibrary.js", encoding="utf-8").read()
assert 'class="email-tags-more email-tags-more-single"' not in library
group = library[library.index("function _emailTagGroupHtml("):library.index("function _fitEmailCardTags(")]
assert "if (visible.length === 1) return visible[0];" in group
assert "if (visible.length === 2) return visible.join('');" in group
assert group.index("if (visible.length === 2)") < group.index("email-tags-more-single")
def test_email_folder_and_filter_pickers_treat_their_buttons_as_inside_clicks():
@@ -181,7 +183,7 @@ def test_empty_reply_has_two_editable_rows_and_reply_survives_compact_toolbar():
fit_start = library.index("function _fitReaderActions")
fit_end = library.index("const _readerActionFitObserver", fit_start)
fit = library[fit_start:fit_end]
assert '[data-act="ai-reply"], [data-act="reply-all"]' in fit
assert '[data-act="reply-all"], [data-act="forward"]' in fit
assert '[data-act="reply"]' not in fit
+1 -1
View File
@@ -16,7 +16,7 @@ def test_folder_chip_stays_with_date_and_moves_down():
folder_rule_start = css.index(".email-folder-chip {", first_folder_rule + 1)
folder_rule = css[folder_rule_start:][:220]
assert "position: relative;" in folder_rule
assert "top: 2px;" in folder_rule
assert "top: 4px;" in folder_rule
group_rule = css[css.index(".email-meta-date-group {"):][:180]
assert "display: inline-flex;" in group_rule
assert "white-space: nowrap;" in group_rule
+4 -3
View File
@@ -127,15 +127,16 @@ def test_attachment_warning_only_checks_authored_reply_text():
assert "text.split(/^>|^On .* wrote:/m)" not in helper
def test_email_send_saves_recovery_draft_before_send_and_closes_only_on_success():
def test_email_send_saves_recovery_draft_before_send_and_retains_it_on_failure():
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
send = source[source.index("async function _sendEmail"):source.index("\n\n async function _saveDraft", source.index("async function _sendEmail"))]
assert "async function _saveEmailDraftForRecovery" in source
assert send.index("_saveEmailDraftForRecovery({") < send.index("fetch(`${API_BASE}/api/email/send`")
assert send.index("if (isLibraryOpen()) closeLibrary();") > send.index("const sendRequest = fetch(")
assert send.index("if (isLibraryOpen()) closeLibrary();") < send.index("const res = await sendRequest")
success_branch = send[send.index("if (data.success) {"):]
assert "closeLibrary()" in success_branch
assert send.index("closeLibrary()") > send.index("const res = await sendRequest")
assert "fetch(`${API_BASE}/api/document/${sendDocId}`, { method: 'DELETE' })" in success_branch
assert "Draft kept in Drafts." in send
assert "recoveryDraft.draft_uid" in send
@@ -10,8 +10,11 @@ def test_email_folder_and_filter_pickers_are_exclusive_and_escape_safe():
assert source.count("const filterMenu = document.getElementById('email-filter-menu');") == 1
assert source.count("const folderMenu = document.getElementById('email-folder-menu');") == 1
assert source.count("e.stopImmediatePropagation();") >= 2
assert "_wireEmailPickerEscapeGuard();" in source
assert "window.addEventListener('keydown'" in source
assert source.count("e.stopImmediatePropagation?.();") >= 2
assert "filterMenu?._dismiss?.();" in source
assert "folderMenu?._dismiss?.();" in source
escape_handler = source[source.index("state._libInnerEscHandler = (e) => {"):source.index("window.addEventListener('keydown', state._libInnerEscHandler, true)")]
assert "if (dismissTopMenu()) {" in escape_handler
assert "window.addEventListener('keydown', state._libInnerEscHandler, true)" in source
inbox = (ROOT / "static/js/emailInbox.js").read_text(encoding="utf-8")
assert re.search(r"from './emailLibrary\.js\?v=[A-Za-z0-9_-]+'", inbox)
+8 -5
View File
@@ -22,7 +22,9 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None:
id: 'escape-doc', title: 'Escape', language: 'richtext',
current_content: '<p>Selected sentence for testing</p>', version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 700));
});
await page.waitForSelector('#doc-email-richbody p');
await page.evaluate(() => {
const rich = document.querySelector('#doc-email-richbody');
const range = document.createRange();
range.selectNodeContents(rich.querySelector('p'));
@@ -32,7 +34,8 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None:
rich.focus();
rich.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 20, clientY: 20 }));
});
await page.waitForTimeout(100);
await page.waitForSelector('#doc-rich-selection-toolbar');
await page.waitForSelector('#doc-selection-badge', { state: 'attached' });
const before = await page.evaluate(() => ({
toolbar: !!document.querySelector('#doc-rich-selection-toolbar'),
badge: document.querySelector('#doc-selection-badge')?.style.display ?? 'missing',
@@ -63,7 +66,7 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None:
data = json.loads(result.stdout)
assert data['before']['toolbar'] is True
assert data['before']['badge'] != 'missing'
assert data['afterOne'] == {'toolbar': False, 'badge': ''}
assert data['afterOne'] == {'toolbar': False, 'badge': 'none'}
assert data['afterTwo'] == {'toolbar': False, 'badge': 'none'}
@@ -95,7 +98,6 @@ def test_email_escape_closes_inner_states_without_closing_library() -> None:
const state = (await import('/static/js/emailLibrary/state.js')).state;
const modal = document.querySelector('#email-lib-modal');
state._selectMode = true;
modal.classList.add('email-reading');
});
await page.keyboard.press('Escape');
const select = await page.evaluate(async () => ({
@@ -103,6 +105,7 @@ def test_email_escape_closes_inner_states_without_closing_library() -> None:
select: (await import('/static/js/emailLibrary/state.js')).state._selectMode,
reading: document.querySelector('#email-lib-modal')?.classList.contains('email-reading'),
}));
await page.evaluate(() => document.querySelector('#email-lib-modal').classList.add('email-reading'));
await page.keyboard.press('Escape');
const reading = await page.evaluate(() => ({
modal: !!document.querySelector('#email-lib-modal'),
@@ -121,5 +124,5 @@ def test_email_escape_closes_inner_states_without_closing_library() -> None:
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data['settings'] == {'modal': True, 'settings': False}
assert data['select'] == {'modal': True, 'select': False, 'reading': True}
assert data['select'] == {'modal': True, 'select': False, 'reading': False}
assert data['reading'] == {'modal': True, 'reading': False}
+1 -16
View File
@@ -5,22 +5,7 @@ with the args on the same line as the tag; the parser must execute those. The
relaxed tag pattern must NOT prefix-match longer fence tags: ```python3 is a
language hint, not a "python" tool call with content "3\n...".
"""
import sys
from unittest.mock import MagicMock
for mod in ['src.agent_tools', 'src.tool_parsing', 'src.tool_schemas', 'src.tool_execution']:
sys.modules.pop(mod, None)
for mod in [
'sqlalchemy', 'sqlalchemy.orm', 'sqlalchemy.ext', 'sqlalchemy.ext.declarative',
'sqlalchemy.ext.hybrid', 'sqlalchemy.sql', 'sqlalchemy.sql.expression',
'src.database', 'core.models', 'core.database', 'core.auth'
]:
if mod not in sys.modules:
sys.modules[mod] = MagicMock()
import src.agent_tools # noqa: E402, F401
from src.tool_parsing import parse_tool_blocks, strip_tool_blocks # noqa: E402
from src.tool_parsing import parse_tool_blocks, strip_tool_blocks
def test_inline_args_on_tag_line_parse():
# The original bug: ```list_email_accounts {} (args on the tag line)
+1 -16
View File
@@ -3,22 +3,7 @@ can't be converted (e.g. a hyphenated/namespaced tool name that _XML_INVOKE_RE's
\\w+ won't match, or an unknown tool) must NOT fall through and ship the raw XML
to the code executor as if it were python/bash.
"""
import sys
from unittest.mock import MagicMock
for mod in ['src.agent_tools', 'src.tool_parsing', 'src.tool_schemas', 'src.tool_execution']:
sys.modules.pop(mod, None)
for mod in [
'sqlalchemy', 'sqlalchemy.orm', 'sqlalchemy.ext', 'sqlalchemy.ext.declarative',
'sqlalchemy.ext.hybrid', 'sqlalchemy.sql', 'sqlalchemy.sql.expression',
'src.database', 'core.models', 'core.database', 'core.auth'
]:
if mod not in sys.modules:
sys.modules[mod] = MagicMock()
import src.agent_tools # noqa: E402, F401
from src.tool_parsing import parse_tool_blocks # noqa: E402
from src.tool_parsing import parse_tool_blocks
def test_unconvertible_invoke_in_fence_is_not_executed_as_code():
text = '```python\n<invoke name="foo-bar">\n<parameter name="x">1</parameter>\n</invoke>\n```'
+8 -1
View File
@@ -147,6 +147,7 @@ def _chat_stream_endpoint(
capture_context=False,
endpoint_url="https://selected.example/v1",
session_model="selected-model",
session_history=(),
):
def add_message(message):
captured.setdefault("added_messages", []).append(message)
@@ -156,7 +157,7 @@ def _chat_stream_endpoint(
model=session_model,
headers={"Authorization": "Bearer selected"},
name="test",
history=[],
history=list(session_history),
add_message=add_message,
)
session_manager = SimpleNamespace(
@@ -2504,6 +2505,7 @@ def test_late_agent_fallback_records_each_round_and_stays_pinned(monkeypatch):
headers=primary[2],
max_rounds=4,
relevant_tools={"bash"},
workspace="/workspace",
fallbacks=[backup],
fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
fallback_on_empty=False,
@@ -2611,6 +2613,7 @@ def test_agent_terminal_later_round_error_stops_after_completed_tool(
[{"role": "user", "content": "Run one tool."}],
max_rounds=3,
relevant_tools={"bash"},
workspace="/workspace",
fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
fallback_on_empty=False,
_is_teacher_run=True,
@@ -3033,6 +3036,7 @@ def test_agent_metrics_attribute_usage_to_each_answering_route(monkeypatch):
headers=primary[2],
max_rounds=3,
relevant_tools={"bash"},
workspace="/workspace",
fallbacks=[backup],
route_descriptors=[
{"endpoint_id": "paid", "endpoint_label": "Paid", "endpoint_cost_tracked": True},
@@ -3216,6 +3220,7 @@ def test_force_answer_recovery_persists_and_bills_pinned_fallback_route(
headers=primary[2],
max_rounds=6,
relevant_tools={"bash"},
workspace="/workspace",
fallbacks=[backup],
route_descriptors=[
{
@@ -3307,6 +3312,7 @@ def test_agent_terminal_retains_completed_paid_fallback_usage(monkeypatch):
headers=primary[2],
max_rounds=3,
relevant_tools={"bash"},
workspace="/workspace",
fallbacks=[backup],
route_descriptors=[
{"endpoint_id": "local", "endpoint_label": "Local", "endpoint_cost_tracked": False},
@@ -3506,6 +3512,7 @@ def test_agent_fallback_request_uses_candidate_context_budget(
headers=primary[2],
max_rounds=2,
relevant_tools={"bash"},
workspace="/workspace",
fallbacks=[backup],
fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
fallback_on_empty=False,
+1 -2
View File
@@ -5,10 +5,9 @@ import re
ROOT = Path(__file__).resolve().parents[1]
def test_original_harness_version_is_canonical_semver() -> None:
def test_harness_version_is_canonical_semver() -> None:
version = (ROOT / "HARNESS_VERSION").read_text(encoding="utf-8").strip()
assert version == "0.20.18"
assert re.fullmatch(r"\d+\.\d+\.\d+", version)
+1
View File
@@ -105,6 +105,7 @@ async def test_api_call_root_path_has_no_trailing_slash():
with (
patch.object(integrations, "_find_integration", return_value=DISCORD_INTEGRATION),
patch("src.url_safety._default_resolver", return_value=["93.184.216.34"]),
patch("httpx.AsyncClient", return_value=mock_client),
):
result = await integrations.execute_api_call(
+3 -3
View File
@@ -145,15 +145,15 @@ def test_final_response_skips_duplicate_end_of_stream_render():
"// --- Final render (skip if stream was ever backgrounded or currently in background) ---",
"} // end if (!_isBgFinal)",
)
assert "if (!terminalFinalResponseRendered) _renderStream();" in post_loop
assert "if (!terminalFinalResponseRendered && !_turnRendering.isVisible(roundHolder.querySelector('.body'), _terminalAnswerHtml(roundText, roundHolder.querySelector('.body')))) _renderStream();" in post_loop
assert "const finalDisplay = terminalFinalResponseRendered ? '' : _streamDisplayText" in post_loop
def test_only_rich_completed_turns_reload_saved_canonical_assistant_row():
assert "if (_canonicalTerminalSaved && _savedAssistantMessageId && !_pendingContinue)" not in _CHAT
assert "if (!_pendingContinue)" in _CHAT
assert "fall back to its latest" in _CHAT
assert ": [...history].reverse().find(msg => msg && msg.role === 'assistant');" in _CHAT
assert "if (!streamSessionId || !_savedAssistantMessageId) return false;" in _CHAT
assert "String(msg.metadata?._db_id || '') === _savedAssistantMessageId" in _CHAT
assert "const _needsCanonicalTurnRebuild" in _CHAT
assert "lastToolThread" in _CHAT
assert "_streamTurnMarker.remove();" in _CHAT
+1 -1
View File
@@ -172,4 +172,4 @@ class TestThinkSuppression:
"qwen35-9b-tool-router-v4-firstaction-noschema-adapter",
)
assert "think" not in payload
assert payload["max_tokens"] == 1024
assert payload["max_tokens"] == llm_core.LLMConfig.DEFAULT_MAX_TOKENS
+1 -1
View File
@@ -102,7 +102,7 @@ globalThis.document = {
globalThis.MutationObserver = class { observe() {} };
let source = fs.readFileSync('./static/js/markdown.js', 'utf8');
source = source.replace(/import uiModule from ['"]\.\/ui\.js['"];/, '');
source = source.replace(/import uiModule from ['"]\.\/ui\.js(?:[?#][^'"]*)?['"];?/, '');
source = source.replace(
/import \{ splitTableRow \} from ['"]\.\/markdown\/tableRow\.js['"];/,
`function splitTableRow(row) {
+1 -1
View File
@@ -53,7 +53,7 @@ def _run_markdown_case(markdown: str, render_expr: str = "mod.mdToHtml(input)",
let source = fs.readFileSync('./static/js/markdown.js', 'utf8');
source = source.replace(
/import uiModule from ['"]\.\/ui\.js['"];/,
/import uiModule from ['"]\.\/ui\.js(?:[?#][^'"]*)?['"];?/,
''
);
source = source.replace(
+6 -2
View File
@@ -135,7 +135,7 @@ def test_minimal_notes_clamp_suppresses_admin_schema_expansion() -> None:
clamp = source[source.index("if _minimal_explicit_notes_mode"):]
assert clamp.index("_needs_admin = False") < clamp.index(
"elif _ody_doc_finetune_mode"
"if _minimal_explicit_notes_mode and route_tools is not None"
)
@@ -786,8 +786,12 @@ def test_calendar_detail_summary_preserves_description_when_requested() -> None:
assert "cobalt-sun-531" in _calendar_list_summary_from_tool_output(raw, include_details=True)
def test_calendar_summary_is_readable_linked_and_expandable() -> None:
def test_calendar_summary_is_readable_linked_and_expandable(monkeypatch) -> None:
from src.agent_loop import _calendar_list_summary_from_tool_output
from datetime import timezone
import src.user_time
monkeypatch.setattr(src.user_time, "user_timezone", lambda: timezone.utc)
raw = "\n".join(
[
@@ -87,6 +87,12 @@ def test_mobile_bulk_select_long_press_is_shared_across_card_types():
assert ".skill-card[data-skill-name]" in helper
assert ".task-card[data-id]" in helper
assert "const HOLD_MS = 450" in helper
assert "body:has(#memory-select-btn.active)" in css
assert "body:has(#skills-select-btn.active)" in css
assert "body:has(#tasks-select-btn.active)" in css
shared_buttons = (
"#memory-select-btn,", "#skills-select-btn,",
"#notes-select-btn,", "#tasks-select-btn,",
)
shared_css = css.split("/* Shared bulk-selection trigger.", 1)[1]
selectors = re.findall(r":is\(([^)]*)\)(?:\.active)?::before", shared_css)
assert len(selectors) == 2
for selector in selectors:
assert all(button in selector for button in shared_buttons)
+2 -2
View File
@@ -9,7 +9,7 @@ def test_notes_sidebar_new_action_matches_library_structure_and_animation():
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
assert 'class="list-item-plus-btn sidebar-new-item-btn" id="notes-new-note-btn"' in html
assert '<span class="list-item-plus-label">note</span>' in html
assert '<span class="list-item-plus-label">new</span>' in html[html.index('id="notes-new-note-btn"'):]
assert "#tool-notes-btn:hover #notes-new-note-btn" in css
assert "#notes-new-note-btn:hover svg" in css
@@ -72,7 +72,7 @@ def test_notes_body_and_tag_arrow_are_nudged_down():
assert css.count("padding: 10px 8px 8px") >= 2
assert ".notes-pane-body .doclib-chip-scroll-arrow.right" in css
assert "top: calc(50% - 2px) !important" in css
assert "top: calc(50% - 6px) !important" in css[css.index(".notes-pane-body .doclib-chip-scroll-arrow.right"):]
def test_notes_tag_strip_cannot_grow_into_blank_space():
+31 -12
View File
@@ -15,6 +15,11 @@ NOTES_TOOLS = {
"ask_user",
"update_plan",
}
GENERAL_COMPACT_TOOLS = {"ask_user", "web_search", "web_fetch"}
CALENDAR_COMPACT_TOOLS = {
"ask_user", "bash", "get_workspace", "manage_calendar", "manage_notes",
"python", "read_file", "web_fetch", "web_search",
}
def _collect(generator):
@@ -102,9 +107,7 @@ def test_odysseus_notes_mode_clamps_without_overriding_caller_denials(monkeypatc
assert {"manage_notes", "manage_calendar", "manage_tasks"} <= route["disabled_tools"]
def test_odysseus_general_mode_disables_every_tool(monkeypatch):
from src.tool_policy import known_tool_names
def test_odysseus_router_uses_compact_core(monkeypatch):
prompt_calls, _ = _install_route_probe(monkeypatch)
_run_probe(
@@ -113,11 +116,27 @@ def test_odysseus_general_mode_disables_every_tool(monkeypatch):
)
route = prompt_calls[0]
assert route["relevant_tools"] == set()
assert known_tool_names() <= route["disabled_tools"]
assert route["relevant_tools"] == GENERAL_COMPACT_TOOLS
def test_odysseus_calendar_intent_uses_notes_mode(monkeypatch):
def test_odysseus_general_no_tool_mode_has_no_executable_surface(monkeypatch):
from src.tool_policy import known_tool_names
# The current merged profile takes the compact-router branch. Exercise
# the legacy general mode itself so its execution denial stays covered.
monkeypatch.setattr(agent_loop, "_is_qwen38_tool_router", lambda model: False)
prompt_calls, stream_calls = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Explain the CAP theorem with a concrete distributed database example."}],
relevant_tools={"bash", "manage_notes", "ask_user"},
)
assert stream_calls[0]["tools"] is None
assert known_tool_names() <= prompt_calls[0]["disabled_tools"]
def test_odysseus_calendar_intent_uses_compact_calendar_route(monkeypatch):
prompt_calls, _ = _install_route_probe(monkeypatch)
_run_probe(
@@ -125,10 +144,10 @@ def test_odysseus_calendar_intent_uses_notes_mode(monkeypatch):
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
)
assert prompt_calls[0]["relevant_tools"] == NOTES_TOOLS
assert prompt_calls[0]["relevant_tools"] == CALENDAR_COMPACT_TOOLS
def test_odysseus_calendar_followup_keeps_notes_mode(monkeypatch):
def test_odysseus_calendar_followup_keeps_compact_calendar_route(monkeypatch):
prompt_calls, _ = _install_route_probe(monkeypatch)
messages = [
{"role": "user", "content": "Add lunch tomorrow to my calendar."},
@@ -153,7 +172,7 @@ def test_odysseus_calendar_followup_keeps_notes_mode(monkeypatch):
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
)
assert prompt_calls[0]["relevant_tools"] == NOTES_TOOLS
assert prompt_calls[0]["relevant_tools"] == CALENDAR_COMPACT_TOOLS
def test_agent_route_passes_workspace_to_system_prompt(monkeypatch):
@@ -169,7 +188,7 @@ def test_agent_route_passes_workspace_to_system_prompt(monkeypatch):
assert prompt_calls[0]["workspace"] == "/tmp/example-repo"
def test_odysseus_qwen_temperature_is_capped_for_agent_requests(monkeypatch):
def test_odysseus_compact_primary_uses_deterministic_temperature(monkeypatch):
_, stream_calls = _install_route_probe(monkeypatch)
_run_probe(
@@ -178,7 +197,7 @@ def test_odysseus_qwen_temperature_is_capped_for_agent_requests(monkeypatch):
temperature=1.2,
)
assert stream_calls[0]["temperature"] == 0.2
assert stream_calls[0]["temperature"] == 0.0
def test_qwen_fallback_candidate_gets_capped_temperature(monkeypatch):
@@ -214,7 +233,7 @@ def test_non_qwen_fallback_keeps_requested_temperature(monkeypatch):
fallbacks=[("https://backup.example/v1", "gpt-4o", {})],
)
assert stream_calls[0]["temperature"] == 0.2
assert stream_calls[0]["temperature"] == 0.0
factory = stream_calls[0]["candidate_request_factory"]
request = asyncio.run(factory(1, "https://backup.example/v1", "gpt-4o", {}))
assert request["kwargs"]["temperature"] == 1.2
+6 -3
View File
@@ -223,10 +223,11 @@ async def test_native_transcription_turn_does_not_offer_shell_fallbacks(
contract = observed[0]
assert contract is not None
assert contract.capabilities == {"transcription"}
assert contract.offered == {"transcribe_media"}
assert contract.offered == {"transcribe_media", "ask_user"}
assert "bash" not in contract.offered
assert "python" not in contract.offered
assert "inspect_media" not in contract.offered
assert contract.permits("transcribe_media")
@pytest.mark.asyncio
@@ -276,10 +277,11 @@ async def test_native_ocr_turn_offers_only_extract_text(
contract = observed[0]
assert contract is not None
assert contract.capabilities == {"ocr"}
assert contract.offered == {"extract_text"}
assert contract.offered == {"extract_text", "ask_user"}
assert "inspect_media" not in contract.offered
assert "bash" not in contract.offered
assert "python" not in contract.offered
assert contract.permits("extract_text")
@pytest.mark.asyncio
@@ -370,8 +372,9 @@ async def test_exact_odysseus_clean_route_offers_only_requested_compact_family(
contract = observed[0]
assert contract.selection_mode == "clean_compact_v3_preview"
assert contract.capabilities == {"tasks"}
assert contract.offered == {"manage_tasks"}
assert contract.offered == {"manage_tasks", "ask_user"}
assert contract.required == {"manage_tasks"}
assert contract.permits("manage_tasks")
@pytest.mark.asyncio
+7 -1
View File
@@ -15,6 +15,7 @@ import sys
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
# The helper resolves `from src.database import ModelEndpoint` at call time.
# Stub the module so we can hand it a fake declarative class whose column
@@ -73,12 +74,17 @@ class _DB:
return _Query(self._rows)
@pytest.fixture(autouse=True)
def _isolate_model_endpoint(monkeypatch):
import src.database
monkeypatch.setattr(src.database, "ModelEndpoint", _ModelEndpoint)
def _ep(eid, owner, *, is_enabled=True):
return SimpleNamespace(id=eid, owner=owner, is_enabled=is_enabled, api_key="sk-secret")
def _resolve(rows, owner, endpoint_id=None):
sys.modules["src.database"].ModelEndpoint = _ModelEndpoint
return _owned_enabled_endpoint(_DB(rows), owner, endpoint_id)
+16 -7
View File
@@ -1,6 +1,7 @@
"""Regression coverage for full-height Deep Research history expansion."""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parent.parent
@@ -11,7 +12,11 @@ def test_history_list_participates_in_shared_library_expansion_layout():
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
assert 'id="research-past-list" class="doclib-grid memory-list research-jobs-list"' in panel
assert "width:min(560px, 90vw);max-height:78vh" in panel
card = css[css.index("#research-pane .research-history-card {"):]
card = card[:card.index("}")]
assert "flex: 1 1 0;" in card
assert "min-height: 0;" in card
assert "overflow: hidden;" in card
assert 'class="memory-tab-panel research-tab-panel"' in panel
assert 'class="admin-card research-new-job"' in panel
assert 'id="research-history-filters" class="skills-summary-strip"' in panel
@@ -21,7 +26,11 @@ def test_history_list_participates_in_shared_library_expansion_layout():
assert "pane.classList.toggle('research-results-view', tab !== 'research')" in panel
assert "display: flex; flex-direction: column; gap: 10px;" in css
assert ".research-tabs {\n flex: 0 0 auto;\n margin: -4px -4px 0;" in css
assert "font-size:11px; letter-spacing:0;" in css
title = css[css.index("#research-pane .research-history-title-row h2 {"):]
title = title[:title.index("}")]
assert "letter-spacing: 0;" in title
desc = css[css.index("#research-pane .research-history-desc {"):]
assert "font-size: 11px;" in desc[:desc.index("}")]
assert "font: inherit; font-size: 12px; cursor: pointer; text-align: left;" in css
assert "#research-pane .research-history-search-wrap .memory-search-input" in css
assert "height: 30px;" in css
@@ -65,8 +74,8 @@ def test_research_panel_uses_one_versioned_module_instance():
renderer = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
index = (ROOT / "static/index.html").read_text(encoding="utf-8")
version = "20260902researchhistoryalign34"
assert f"research/panel.js?v={version}" in app
assert renderer.count(f"research/panel.js?v={version}") == 2
assert f"style.css?v={version}" in index
assert f"app.js?v={version}" in index
panel_version = re.search(r"research/panel\.js\?v=([^'\"]+)", app)
asset_version = re.search(r"/static/app\.js\?v=([^'\"]+)", index)
style_version = re.search(r"/static/style\.css\?v=([^'\"]+)", index)
assert panel_version and renderer.count(f"research/panel.js?v={panel_version.group(1)}") == 2
assert asset_version and style_version and asset_version.group(1) == style_version.group(1)
+6 -6
View File
@@ -25,18 +25,18 @@ def test_research_settings_use_custom_pickers_and_shared_provider_icons():
assert "provider === 'google' ? 'google_pse'" in icons
first_picker_offset = style.split(".research-setting:has(#research-rounds),", 1)[1].split("}", 1)[0]
assert ".research-setting:has(#research-category)" in first_picker_offset
assert "top: -4px;" in first_picker_offset
assert "top: 0;" in first_picker_offset
def test_completed_research_keeps_primary_and_utility_actions_visible_without_format_icon():
panel = (ROOT / "static/js/research/panel.js").read_text(encoding="utf-8")
assert "research-job-format-icon" not in panel
assert "Visual Report" in panel
assert "Discuss" in panel
assert 'data-action="copy" title="Copy report to clipboard"' in panel
assert 'data-action="dismiss" title="Clear from list"' in panel
assert 'data-action="delete" title="Delete from disk"' in panel
assert 'data-action="report" title="Open visual report"' in panel
assert 'data-action="chat" title="Open follow-up chat with this research as context"' in panel
assert "{ action: 'copy', icon: _copyIcon, label: 'Copy report' }" in panel
assert "{ action: 'dismiss', icon: _cancelIcon, label: 'Hide from list' }" in panel
assert "{ action: 'delete', icon: _trashIcon, label: 'Delete from disk', danger: true }" in panel
def test_research_format_survives_live_and_reconnected_jobs():
+2 -2
View File
@@ -8,11 +8,11 @@ def test_active_research_visualizer_has_live_hierarchy_and_balanced_layout():
source = (ROOT / "static/js/researchSynapse.js").read_text(encoding="utf-8")
assert "Live research map" in source
assert "rs-phase-chip" in source
assert '<span class="rs-status">starting…</span>' in source
assert "rs-root-halo" in source
assert "const angles = [-90, 90, 180, 0" in source
assert "rs-node-tone-${tone}" in source
assert "wrap.classList.add(`rs-phase-${phase}`)" in source
assert "if (phase) wrap.classList.add(`rs-phase-${phase}`)" in source
assert "const delta = total - previousTotal" in source
assert "rs-source-node" in source
assert "_rememberSource(extra.title, extra.url)" in source
+3 -1
View File
@@ -114,6 +114,7 @@ async def test_docx_preview_runs_off_loop_and_checks_owner(monkeypatch, conversi
def test_imported_office_document_is_owned_at_first_commit(monkeypatch, tmp_path):
import importlib
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
import src.database as database
@@ -122,7 +123,8 @@ def test_imported_office_document_is_owned_at_first_commit(monkeypatch, tmp_path
database.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
monkeypatch.setattr(database, "SessionLocal", factory)
monkeypatch.setattr("src.agent_tools.document_tools.set_active_document", lambda doc_id: None)
document_tools = importlib.import_module("src.agent_tools.document_tools")
monkeypatch.setattr(document_tools, "set_active_document", lambda doc_id: None)
owners_at_commit = []
def inspect_new_rows(session):
owners_at_commit.extend(row.owner for row in session.new if isinstance(row, database.Document))
+40 -13
View File
@@ -19,17 +19,7 @@ _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 { providerLogo } from './providers.js';": (
"import { providerLogo } from './providers.mjs';"
),
"import themeModule from './theme.js';": "import themeModule from './theme.mjs';",
"import spinnerModule from './spinner.js';": "import spinnerModule from './spinner.mjs';",
}
_IMPORT_REWRITES = {}
_STUBS = {
"storage.mjs": r"""
@@ -66,6 +56,15 @@ export default ui;
),
"theme.mjs": "export default {};\n",
"spinner.mjs": "export default {};\n",
"actionMenuOrder.mjs": (
"export const SELECT_MENU_ICON = '';\n"
"export const actionMenuRank = () => 0;\n"
"export const orderActionMenuItems = (items) => items;\n"
),
"escMenuStack.mjs": (
"export const registerEscapeLayer = () => () => {};\n"
"export const bindMenuDismiss = () => {};\n"
),
}
_HARNESS = r"""
@@ -303,13 +302,41 @@ def results(tmp_path_factory):
source = _SESSIONS.read_text(encoding="utf-8")
versioned_rewrites = (
(
r"import chatRenderer from './chatRenderer\.js(?:\?v=[A-Za-z0-9_-]+)?';",
r"import Storage from './storage\.js(?:[?#][^']*)?';",
"import Storage from './storage.mjs';",
),
(
r"import uiModule, \{ autoResize, styledPrompt \} from './ui\.js(?:[?#][^']*)?';",
"import uiModule, { autoResize, styledPrompt } from './ui.mjs';",
),
(
r"import chatRenderer from './chatRenderer\.js(?:[?#][^']*)?';",
"import chatRenderer from './chatRenderer.mjs';",
),
(
r"import \{ initModelPicker, updateModelPicker \} from './modelPicker\.js(?:\?v=[A-Za-z0-9_-]+)?';",
r"import \{ providerLogo \} from './providers\.js(?:[?#][^']*)?';",
"import { providerLogo } from './providers.mjs';",
),
(
r"import \{ initModelPicker, updateModelPicker \} from './modelPicker\.js(?:[?#][^']*)?';",
"import { initModelPicker, updateModelPicker } from './modelPicker.mjs';",
),
(
r"import themeModule from './theme\.js(?:[?#][^']*)?';",
"import themeModule from './theme.mjs';",
),
(
r"import spinnerModule from './spinner\.js(?:[?#][^']*)?';",
"import spinnerModule from './spinner.mjs';",
),
(
r"import \{ actionMenuRank, orderActionMenuItems, SELECT_MENU_ICON \} from './actionMenuOrder\.js(?:[?#][^']*)?';",
"import { actionMenuRank, orderActionMenuItems, SELECT_MENU_ICON } from './actionMenuOrder.mjs';",
),
(
r"import \{ registerEscapeLayer, bindMenuDismiss \} from './escMenuStack\.js(?:[?#][^']*)?';",
"import { registerEscapeLayer, bindMenuDismiss } from './escMenuStack.mjs';",
),
)
for pattern, replacement in versioned_rewrites:
source, count = re.subn(pattern, replacement, source, count=1)
@@ -1,4 +1,5 @@
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
@@ -83,7 +84,9 @@ def test_large_tool_scroll_fix_is_served_under_a_fresh_chat_module_key():
"""The fixed ui module is imported by chat.js, so stale chat.js is stale UI."""
app = (ROOT / "static/app.js").read_text(encoding="utf-8")
index = (ROOT / "static/index.html").read_text(encoding="utf-8")
key = "chat.js?v=20260916largetoolscroll2"
match = re.search(r"chat\.js\?v=([A-Za-z0-9_-]+)", app)
assert match is not None, "chat.js must be imported with a cache-busting key"
key = f"chat.js?v={match.group(1)}"
assert key in app
assert index.count(key) == 2
+2 -1
View File
@@ -40,7 +40,8 @@ def test_tasks_filter_chips_include_active_paused_switch():
assert "mkChip(`active (${activeCount})`, 'active', _taskStatusFilter === 'active', 'status');" in src
assert "mkChip(`paused (${pausedCount})`, 'paused', _taskStatusFilter === 'paused', 'status');" in src
assert "if (_taskStatusFilter && String(t.status || '').toLowerCase() !== _taskStatusFilter) return false;" in src
assert "if (value === null) _taskStatusFilter = null;" in src
assert "_taskStatusFilter = _taskStatusFilter === value ? null : value;" in src
assert "_taskStatusFilter = null;" in src[src.index("if (kind === 'status')"):src.index("_renderList();", src.index("if (kind === 'status')"))]
def test_tasks_completed_view_exposes_active_paused_shortcuts():
+2 -1
View File
@@ -20,11 +20,12 @@ def _run_tile_case():
innerWidth: 1200,
innerHeight: 800,
addEventListener() {{}},
getComputedStyle() {{ return {{ display: 'block' }}; }},
}};
let sidebarVisible = false;
const sidebar = {{
classList: {{ contains(name) {{ return name === 'hidden' ? !sidebarVisible : false; }} }},
getBoundingClientRect() {{ return {{ right: 240 }}; }},
getBoundingClientRect() {{ return {{ left: 0, right: 240, width: 240, top: 0, bottom: 800 }}; }},
}};
globalThis.document = {{
readyState: 'loading',
+4 -4
View File
@@ -155,12 +155,12 @@ def test_every_changed_approval_module_is_cache_busted_together():
assert found, f"missing cache-busted reference for {module_name}"
assert len(set(found)) == 1, f"split module graph for {module_name}: {found}"
# These shared modules are imported throughout the graph. Keep their URL
# canonical and unversioned; mixing a query URL with plain relative imports
# creates a second singleton with separate state and listeners.
for module_name in ("sessions.js", "ui.js", "memory.js", "markdown.js", "models.js"):
# Shared modules must have one URL apiece. ui.js is consistently versioned
# throughout the graph; the other shared modules remain unversioned.
for module_name in ("sessions.js", "memory.js", "markdown.js", "models.js"):
assert any(module_name in source for source in sources)
assert not versions(module_name), f"split module graph for {module_name}"
assert len(set(versions("ui.js"))) == 1
compare_stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
compare_vote = (root / "static/js/compare/vote.js").read_text(encoding="utf-8")
+1 -1
View File
@@ -304,7 +304,7 @@ async def test_write_file_dispatch_rejects_empty_directory_like_workspace_path(m
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert desc == "write_file: /workspace/papers"
assert "refusing to create an empty file at a directory-like path" in (
assert "content required; refusing to create an empty file" in (
result.get("error") or ""
)
assert result.get("exit_code") == 1
+1 -1
View File
@@ -460,7 +460,7 @@ async def test_experiment_request_uses_compact_tools_auto_choice_and_no_thinking
message='List my notes')
contract = select_experiment_inventory(inventory, routed, [], mode)
_ = [chunk async for chunk in preview.stream_preview(
endpoint_url='http://test', model='test', messages=[{'role': 'user', 'content': 'Hi'}],
endpoint_url='http://test', model='test', messages=[{'role': 'user', 'content': 'List my notes'}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=policy,
)]
@@ -73,6 +73,7 @@ def test_tool_task_cancelled_on_generator_close(monkeypatch):
[{"role": "user", "content": "run sleep 60"}],
max_rounds=2,
relevant_tools={"bash"},
workspace="/workspace",
)
saw_tool_start = False
saw_tool_progress = False
+1 -1
View File
@@ -1672,7 +1672,7 @@ def test_missing_supplemental_inventory_is_explicit(family):
@pytest.mark.parametrize("policy", [ToolPolicy(), ToolPolicy(block_all_tool_calls=True)])
def test_empty_selection_means_no_tools(policy):
contract = resolve(policy=policy)
contract = resolve(policy=policy, selected_tools=())
assert contract.offered == contract.required == contract.unavailable == frozenset()
assert contract.schemas() == []
assert not contract.permits("manage_calendar")
+1 -1
View File
@@ -191,7 +191,7 @@ async def test_chat_image_upload_is_added_to_gallery(tmp_path, monkeypatch):
up.setup_upload_routes(h)
endpoint = _endpoint(up.router)
result = await endpoint(_request(user="alice"), [_image_upload()])
result = await endpoint(_request(user="alice"), [_image_upload()], gallery_id=None)
uploaded = result["files"][0]
assert uploaded["gallery_id"]
+1 -15
View File
@@ -9,22 +9,8 @@ That is an intended tool call in non-native/textual tool mode, but older parsing
only recognized fenced blocks, [TOOL_CALL], XML invoke, and tool_code markup.
"""
import json
import sys
from unittest.mock import MagicMock
for mod in ['src.agent_tools', 'src.tool_parsing', 'src.tool_schemas', 'src.tool_execution']:
sys.modules.pop(mod, None)
for mod in [
'sqlalchemy', 'sqlalchemy.orm', 'sqlalchemy.ext', 'sqlalchemy.ext.declarative',
'sqlalchemy.ext.hybrid', 'sqlalchemy.sql', 'sqlalchemy.sql.expression',
'src.database', 'core.models', 'core.database', 'core.auth'
]:
if mod not in sys.modules:
sys.modules[mod] = MagicMock()
import src.agent_tools # noqa: E402, F401
from src.tool_parsing import parse_tool_blocks, strip_tool_blocks # noqa: E402
from src.tool_parsing import parse_tool_blocks, strip_tool_blocks
def test_raw_json_after_web_search_phrase_runs_as_web_search():
text = (
+2 -21
View File
@@ -6,28 +6,9 @@ web_search branch emitted a bare query string and dropped time_filter. These pin
that a valid filter is passed through as JSON, while plain/invalid cases stay a
bare string (back-compat).
"""
import sys
from unittest.mock import MagicMock
# Clean up any mocks from previous tests to ensure we load real modules.
for mod in ['src.agent_tools', 'src.tool_parsing', 'src.tool_schemas', 'src.tool_execution']:
sys.modules.pop(mod, None)
# Mock heavy database/model dependencies before importing (avoids the
# src.tool_schemas <-> src.agent_tools circular import pulling in the DB layer).
for mod in [
'sqlalchemy', 'sqlalchemy.orm', 'sqlalchemy.ext', 'sqlalchemy.ext.declarative',
'sqlalchemy.ext.hybrid', 'sqlalchemy.sql', 'sqlalchemy.sql.expression',
'src.database', 'core.models', 'core.database', 'core.auth'
]:
if mod not in sys.modules:
sys.modules[mod] = MagicMock()
import json # noqa: E402
import src.agent_tools # noqa: E402, F401
from src.tool_schemas import function_call_to_tool_block # noqa: E402
import json
from src.tool_schemas import function_call_to_tool_block
def test_time_filter_is_preserved_as_json():
block = function_call_to_tool_block(
+68 -10
View File
@@ -1037,14 +1037,14 @@ def test_visual_text_extraction_is_distinct_from_speech_transcription():
def test_local_media_routes_select_dedicated_ocr_for_visual_text():
import re
source = (Path(__file__).parents[1] / "src" / "agent_loop.py").read_text()
assert source.count(
"_ocr_requested = _visual_text_extraction_requested(_last_user)"
) == 3
assert source.count(
'{"extract_text"}\n if _ocr_requested'
) == 3
assert len(re.findall(r'\{"extract_text"\}\s*\n\s*if _ocr_requested', source)) == 3
def test_workspace_paths_split_on_chinese_list_punctuation():
@@ -1656,7 +1656,8 @@ def test_local_media_is_exempt_from_pure_web_schema_and_round_clamps():
pure_web_start = source.index(" _local_media_turn = bool(")
pure_web_end = source.index("\n if (\n _pure_web_turn", pure_web_start)
assert "and not _local_media_turn" in source[pure_web_start:pure_web_end]
assert source.count("if _pure_web_turn:") >= 3
assert source.count("if _pure_web_turn:") == 2
assert 'if _pure_web_turn and tool_surface != "full":' in source
def test_empty_local_media_round_nudges_export_instead_of_ending():
@@ -2189,10 +2190,13 @@ def test_python_emits_one_final_bare_expression_without_duplicating_print():
assert explicit["output"] == "once"
def test_python_loaded_code_sees_virtual_workspace_alias(monkeypatch):
def test_python_loaded_code_sees_virtual_workspace_alias(monkeypatch, tmp_path):
"""Absolute /workspace paths must work inside generated Python scripts."""
import asyncio
import shutil
import sys
import venv
from types import SimpleNamespace
if not shutil.which("bwrap"):
return
@@ -2201,21 +2205,75 @@ def test_python_loaded_code_sees_virtual_workspace_alias(monkeypatch):
from src.agent_tools import subprocess_tools
from src import tool_execution
workspace = Path("/home/pewds/odysseus-tool-work")
workspace = tmp_path
environment = tmp_path / "confined-venv"
venv.EnvBuilder(with_pip=False).create(environment)
monkeypatch.setattr(subprocess_tools, "sys", SimpleNamespace(
prefix=str(environment),
executable=str(environment / "bin" / "python"),
version_info=sys.version_info,
))
script = workspace / ".python-workspace-alias-test.py"
output = workspace / ".python-workspace-alias-test.txt"
outside = workspace / "host-sibling.txt"
outside.write_text("must stay hidden from private /tmp")
script.write_text(
"from pathlib import Path; Path('/workspace/.python-workspace-alias-test.txt').write_text('ok')"
"from pathlib import Path; "
"assert not list(Path('/tmp').rglob('host-sibling.txt')); "
"Path('/workspace/.python-workspace-alias-test.txt').write_text('ok')"
)
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(workspace))
result = asyncio.run(subprocess_tools.PythonTool().execute(
f"import runpy; runpy.run_path('{script}', run_name='__main__')",
"import runpy; runpy.run_path('/workspace/.python-workspace-alias-test.py', run_name='__main__')",
{},
))
assert result["exit_code"] == 0, result
assert output.read_text() == "ok"
script.unlink()
output.unlink()
def test_workspace_namespace_mounts_only_a_nested_python_environment(monkeypatch, tmp_path):
import shlex
from src.agent_tools import subprocess_tools
monkeypatch.setattr(subprocess_tools.shutil, "which", lambda name: "/usr/bin/bwrap")
environment = tmp_path / "nested" / "venv"
environment.mkdir(parents=True)
(environment / "pyvenv.cfg").write_text("home = /usr/bin\n")
command = subprocess_tools._wrap_workspace_namespace(
"echo ok", str(tmp_path), interpreter_prefix=str(environment),
)
args = shlex.split(command)
assert ["--ro-bind", str(environment), str(environment)] in [
args[index:index + 3] for index in range(len(args) - 2)
]
assert ["--tmpfs", "/tmp"] in [
args[index:index + 2] for index in range(len(args) - 1)
]
def test_workspace_namespace_rejects_broad_or_symlinked_python_prefixes(monkeypatch, tmp_path):
import shlex
from src.agent_tools import subprocess_tools
monkeypatch.setattr(subprocess_tools.shutil, "which", lambda name: "/usr/bin/bwrap")
linked_root = tmp_path / "linked-root"
linked_root.symlink_to("/", target_is_directory=True)
for unsafe_prefix in ("/", "/tmp", "/var", "/home", str(linked_root)):
command = subprocess_tools._wrap_workspace_namespace(
"echo ok", str(tmp_path), interpreter_prefix=unsafe_prefix,
)
args = shlex.split(command)
assert ["--ro-bind", unsafe_prefix, unsafe_prefix] not in [
args[index:index + 3] for index in range(len(args) - 2)
]
assert ["--tmpfs", "/tmp"] in [
args[index:index + 2] for index in range(len(args) - 1)
]
assert ["--bind", str(tmp_path), "/workspace"] in [
args[index:index + 3] for index in range(len(args) - 2)
]
def test_workspace_namespace_preserves_the_64_bit_dynamic_loader(monkeypatch):