mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
Merge commit '14afd3afb6274ff986733ddff992001e19c80e29' into fix/pr10-merge-ready
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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`\"']+\."
|
||||
|
||||
@@ -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"]',
|
||||
],
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 == (
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"] == {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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```'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user