diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a276fdb1d..4e60561c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.github/workflows/container-trivy.yml b/.github/workflows/container-trivy.yml index ad5674f18..fece5727c 100644 --- a/.github/workflows/container-trivy.yml +++ b/.github/workflows/container-trivy.yml @@ -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: diff --git a/routes/chat_routes.py b/routes/chat_routes.py index 6294d3adb..116cd10df 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -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() diff --git a/src/agent_loop.py b/src/agent_loop.py index f503d7e78..b25ac0e1c 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -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) diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index 5b620cf5f..c8ffe9ddb 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -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 diff --git a/src/turn_contract.py b/src/turn_contract.py index 89467370b..8682e259a 100644 --- a/src/turn_contract.py +++ b/src/turn_contract.py @@ -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`\"']+\." diff --git a/static/js/document.js b/static/js/document.js index f7e01702f..f5964277a 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -7521,6 +7521,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; + @@ -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"]', ], diff --git a/static/js/researchSynapse.js b/static/js/researchSynapse.js index 27e1e2572..87e0099ae 100644 --- a/static/js/researchSynapse.js +++ b/static/js/researchSynapse.js @@ -38,7 +38,7 @@ export default function createResearchSynapse(container, opts = {}) { · 00:00 - + draft\n```', - 'openfile:///workspace/output.html', - "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\ncorrected\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 diff --git a/tests/test_agent_runtime_context.py b/tests/test_agent_runtime_context.py index edd857b11..4514bf187 100644 --- a/tests/test_agent_runtime_context.py +++ b/tests/test_agent_runtime_context.py @@ -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 diff --git a/tests/test_builtin_themes_static.py b/tests/test_builtin_themes_static.py index dde1514fd..f1fbcee60 100644 --- a/tests/test_builtin_themes_static.py +++ b/tests/test_builtin_themes_static.py @@ -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", diff --git a/tests/test_chat_route_tool_policy.py b/tests/test_chat_route_tool_policy.py index 67ce7eaa7..efb955ef2 100644 --- a/tests/test_chat_route_tool_policy.py +++ b/tests/test_chat_route_tool_policy.py @@ -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(): diff --git a/tests/test_chat_ttft_timer_static.py b/tests/test_chat_ttft_timer_static.py index d5d28b430..303f66699 100644 --- a/tests/test_chat_ttft_timer_static.py +++ b/tests/test_chat_ttft_timer_static.py @@ -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 'Input' in RENDERER assert 'Injected' in RENDERER assert 'all rounds' not in RENDERER diff --git a/tests/test_clawmm_r47_malformed_write_body.py b/tests/test_clawmm_r47_malformed_write_body.py index ea2b36ee8..00a35b800 100644 --- a/tests/test_clawmm_r47_malformed_write_body.py +++ b/tests/test_clawmm_r47_malformed_write_body.py @@ -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 == ( diff --git a/tests/test_clean_agent_preview.py b/tests/test_clean_agent_preview.py index 5ac2ae625..e85abc8ac 100644 --- a/tests/test_clean_agent_preview.py +++ b/tests/test_clean_agent_preview.py @@ -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') diff --git a/tests/test_clean_v3_native_workspace.py b/tests/test_clean_v3_native_workspace.py index fc0dde3ba..6c8d0cc22 100644 --- a/tests/test_clean_v3_native_workspace.py +++ b/tests/test_clean_v3_native_workspace.py @@ -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( diff --git a/tests/test_compare_ask_user_routing.py b/tests/test_compare_ask_user_routing.py index 8fbbda856..6fb61cecc 100644 --- a/tests/test_compare_ask_user_routing.py +++ b/tests/test_compare_ask_user_routing.py @@ -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(): diff --git a/tests/test_compare_mobile_tabs_static.py b/tests/test_compare_mobile_tabs_static.py index 15d8366ce..14270050b 100644 --- a/tests/test_compare_mobile_tabs_static.py +++ b/tests/test_compare_mobile_tabs_static.py @@ -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 diff --git a/tests/test_consolidate_memory_explicit_drops.py b/tests/test_consolidate_memory_explicit_drops.py index b03e651bc..d52fd7580 100644 --- a/tests/test_consolidate_memory_explicit_drops.py +++ b/tests/test_consolidate_memory_explicit_drops.py @@ -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): diff --git a/tests/test_copy_message_strips_thinking_js.py b/tests/test_copy_message_strips_thinking_js.py index 4c912925c..86dea41e0 100644 --- a/tests/test_copy_message_strips_thinking_js.py +++ b/tests/test_copy_message_strips_thinking_js.py @@ -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( diff --git a/tests/test_docs_no_orphan_images.py b/tests/test_docs_no_orphan_images.py index f6ed43560..02e0c4494 100644 --- a/tests/test_docs_no_orphan_images.py +++ b/tests/test_docs_no_orphan_images.py @@ -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 diff --git a/tests/test_document_library_expanded_export.py b/tests/test_document_library_expanded_export.py index 812048752..9d9fc5ecc 100644 --- a/tests/test_document_library_expanded_export.py +++ b/tests/test_document_library_expanded_export.py @@ -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 diff --git a/tests/test_document_library_mobile_footer.py b/tests/test_document_library_mobile_footer.py index 186d50be1..e690ecc30 100644 --- a/tests/test_document_library_mobile_footer.py +++ b/tests/test_document_library_mobile_footer.py @@ -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 diff --git a/tests/test_document_rich_font_color_controls.py b/tests/test_document_rich_font_color_controls.py index a4e915f79..7db242e97 100644 --- a/tests/test_document_rich_font_color_controls.py +++ b/tests/test_document_rich_font_color_controls.py @@ -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('', 1)[0] assert 'Font target

Color target

Highlight target

', 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); diff --git a/tests/test_document_rich_keyboard_shortcuts.py b/tests/test_document_rich_keyboard_shortcuts.py index ae2130c00..532a58b5c 100644 --- a/tests/test_document_rich_keyboard_shortcuts.py +++ b/tests/test_document_rich_keyboard_shortcuts.py @@ -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); diff --git a/tests/test_document_rich_slash_menu.py b/tests/test_document_rich_slash_menu.py index 3006b54fb..c7a2fafea 100644 --- a/tests/test_document_rich_slash_menu.py +++ b/tests/test_document_rich_slash_menu.py @@ -52,7 +52,9 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile(): current_content: '

Opening paragraph


', 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"] == { diff --git a/tests/test_document_rich_toolbar_menus.py b/tests/test_document_rich_toolbar_menus.py index 950a2c4f1..658d1b83f 100644 --- a/tests/test_document_rich_toolbar_menus.py +++ b/tests/test_document_rich_toolbar_menus.py @@ -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: '

Toggle target

', 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: '

Paragraph

', 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: '

Stateful text

', 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); diff --git a/tests/test_document_toolbar_order.py b/tests/test_document_toolbar_order.py index 4abdafb8a..ddf6355ab 100644 --- a/tests/test_document_toolbar_order.py +++ b/tests/test_document_toolbar_order.py @@ -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: '

Writing tools

', 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 diff --git a/tests/test_editor_canvas_navigation_js.py b/tests/test_editor_canvas_navigation_js.py index e61eaee05..f20568cbf 100644 --- a/tests/test_editor_canvas_navigation_js.py +++ b/tests/test_editor_canvas_navigation_js.py @@ -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 diff --git a/tests/test_editor_layer_thumbnail_render.py b/tests/test_editor_layer_thumbnail_render.py index 3af077197..aa9e283e1 100644 --- a/tests/test_editor_layer_thumbnail_render.py +++ b/tests/test_editor_layer_thumbnail_render.py @@ -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 diff --git a/tests/test_editor_text_layers_js.py b/tests/test_editor_text_layers_js.py index 9cd5f4815..bf61dd880 100644 --- a/tests/test_editor_text_layers_js.py +++ b/tests/test_editor_text_layers_js.py @@ -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 diff --git a/tests/test_editor_topbar_static.py b/tests/test_editor_topbar_static.py index 0f26b42d5..8f2ed3d50 100644 --- a/tests/test_editor_topbar_static.py +++ b/tests/test_editor_topbar_static.py @@ -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(): diff --git a/tests/test_email_attachment_search.py b/tests/test_email_attachment_search.py index 98dc39f78..02470334b 100644 --- a/tests/test_email_attachment_search.py +++ b/tests/test_email_attachment_search.py @@ -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 diff --git a/tests/test_email_folder_chip_static.py b/tests/test_email_folder_chip_static.py index 41f6d0a3b..193cfa6aa 100644 --- a/tests/test_email_folder_chip_static.py +++ b/tests/test_email_folder_chip_static.py @@ -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 diff --git a/tests/test_email_open_dedup_js.py b/tests/test_email_open_dedup_js.py index b5fea966a..9e5bd9f1c 100644 --- a/tests/test_email_open_dedup_js.py +++ b/tests/test_email_open_dedup_js.py @@ -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 diff --git a/tests/test_email_picker_exclusivity_static.py b/tests/test_email_picker_exclusivity_static.py index 18f217cfa..365514f43 100644 --- a/tests/test_email_picker_exclusivity_static.py +++ b/tests/test_email_picker_exclusivity_static.py @@ -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) diff --git a/tests/test_escape_inner_layers.py b/tests/test_escape_inner_layers.py index a9915531b..e63d3fc6c 100644 --- a/tests/test_escape_inner_layers.py +++ b/tests/test_escape_inner_layers.py @@ -22,7 +22,9 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None: id: 'escape-doc', title: 'Escape', language: 'richtext', current_content: '

Selected sentence for testing

', 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} diff --git a/tests/test_fenced_inline_args.py b/tests/test_fenced_inline_args.py index 0e9bd3c22..76d691010 100644 --- a/tests/test_fenced_inline_args.py +++ b/tests/test_fenced_inline_args.py @@ -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) diff --git a/tests/test_fenced_invoke_no_raw_xml.py b/tests/test_fenced_invoke_no_raw_xml.py index 15d195eb4..e9b6db441 100644 --- a/tests/test_fenced_invoke_no_raw_xml.py +++ b/tests/test_fenced_invoke_no_raw_xml.py @@ -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\n1\n\n```' diff --git a/tests/test_foreground_model_routing.py b/tests/test_foreground_model_routing.py index 02d6cfea6..659c6761b 100644 --- a/tests/test_foreground_model_routing.py +++ b/tests/test_foreground_model_routing.py @@ -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, diff --git a/tests/test_harness_version.py b/tests/test_harness_version.py index d83c5fff0..999491e8f 100644 --- a/tests/test_harness_version.py +++ b/tests/test_harness_version.py @@ -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) diff --git a/tests/test_integrations_url_join.py b/tests/test_integrations_url_join.py index a5e19722e..461b6e380 100644 --- a/tests/test_integrations_url_join.py +++ b/tests/test_integrations_url_join.py @@ -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( diff --git a/tests/test_live_thinking_chat_integration.py b/tests/test_live_thinking_chat_integration.py index ca6e1c864..e8636d310 100644 --- a/tests/test_live_thinking_chat_integration.py +++ b/tests/test_live_thinking_chat_integration.py @@ -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 diff --git a/tests/test_llm_core_ollama_thinking.py b/tests/test_llm_core_ollama_thinking.py index d7d1485ec..305cd6ac8 100644 --- a/tests/test_llm_core_ollama_thinking.py +++ b/tests/test_llm_core_ollama_thinking.py @@ -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 diff --git a/tests/test_markdown_lazy_lib_loading_js.py b/tests/test_markdown_lazy_lib_loading_js.py index e9d781caa..ee334a828 100644 --- a/tests/test_markdown_lazy_lib_loading_js.py +++ b/tests/test_markdown_lazy_lib_loading_js.py @@ -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) { diff --git a/tests/test_markdown_rendering_js.py b/tests/test_markdown_rendering_js.py index 3eb698b3e..10f70c8e5 100644 --- a/tests/test_markdown_rendering_js.py +++ b/tests/test_markdown_rendering_js.py @@ -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( diff --git a/tests/test_minimal_native_tool_prompt.py b/tests/test_minimal_native_tool_prompt.py index f7c09da83..f30972870 100644 --- a/tests/test_minimal_native_tool_prompt.py +++ b/tests/test_minimal_native_tool_prompt.py @@ -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( [ diff --git a/tests/test_notes_mobile_checklist_toggle_static.py b/tests/test_notes_mobile_checklist_toggle_static.py index 1ff792f38..eb8dfdaa9 100644 --- a/tests/test_notes_mobile_checklist_toggle_static.py +++ b/tests/test_notes_mobile_checklist_toggle_static.py @@ -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) diff --git a/tests/test_notes_sidebar_new_note.py b/tests/test_notes_sidebar_new_note.py index c60bbefb2..6caf9f9a3 100644 --- a/tests/test_notes_sidebar_new_note.py +++ b/tests/test_notes_sidebar_new_note.py @@ -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 'note' in html + assert 'new' 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(): diff --git a/tests/test_pr6020_rebase_regressions.py b/tests/test_pr6020_rebase_regressions.py index cacd0ebc0..d92c6bf8c 100644 --- a/tests/test_pr6020_rebase_regressions.py +++ b/tests/test_pr6020_rebase_regressions.py @@ -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 diff --git a/tests/test_product_turn_contract_route.py b/tests/test_product_turn_contract_route.py index d103ede73..8055c6516 100644 --- a/tests/test_product_turn_contract_route.py +++ b/tests/test_product_turn_contract_route.py @@ -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 diff --git a/tests/test_research_endpoint_owner_scope.py b/tests/test_research_endpoint_owner_scope.py index e30e5d994..a4a4b661b 100644 --- a/tests/test_research_endpoint_owner_scope.py +++ b/tests/test_research_endpoint_owner_scope.py @@ -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) diff --git a/tests/test_research_history_expansion.py b/tests/test_research_history_expansion.py index 948dbdaca..d1c692f17 100644 --- a/tests/test_research_history_expansion.py +++ b/tests/test_research_history_expansion.py @@ -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) diff --git a/tests/test_research_panel_ui.py b/tests/test_research_panel_ui.py index 0cfa5a4e3..304b273a7 100644 --- a/tests/test_research_panel_ui.py +++ b/tests/test_research_panel_ui.py @@ -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(): diff --git a/tests/test_research_synapse_ui.py b/tests/test_research_synapse_ui.py index 2a337a359..fb27c1abd 100644 --- a/tests/test_research_synapse_ui.py +++ b/tests/test_research_synapse_ui.py @@ -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 'starting…' 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 diff --git a/tests/test_review_document_conversion.py b/tests/test_review_document_conversion.py index 10499bbdf..4309b2c0a 100644 --- a/tests/test_review_document_conversion.py +++ b/tests/test_review_document_conversion.py @@ -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)) diff --git a/tests/test_startup_session_bootstrap_js.py b/tests/test_startup_session_bootstrap_js.py index 02e7be487..353646751 100644 --- a/tests/test_startup_session_bootstrap_js.py +++ b/tests/test_startup_session_bootstrap_js.py @@ -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) diff --git a/tests/test_stream_completion_scroll_stability.py b/tests/test_stream_completion_scroll_stability.py index 45b90ffec..752c107be 100644 --- a/tests/test_stream_completion_scroll_stability.py +++ b/tests/test_stream_completion_scroll_stability.py @@ -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 diff --git a/tests/test_tasks_completed_default_static.py b/tests/test_tasks_completed_default_static.py index ae37bdedc..44ae48fac 100644 --- a/tests/test_tasks_completed_default_static.py +++ b/tests/test_tasks_completed_default_static.py @@ -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(): diff --git a/tests/test_tile_manager_snap_zones_js.py b/tests/test_tile_manager_snap_zones_js.py index 85f2b8da1..f97c8aa1e 100644 --- a/tests/test_tile_manager_snap_zones_js.py +++ b/tests/test_tile_manager_snap_zones_js.py @@ -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', diff --git a/tests/test_tool_approval_frontend_routing.py b/tests/test_tool_approval_frontend_routing.py index ecf7f3b33..8b2f66d9a 100644 --- a/tests/test_tool_approval_frontend_routing.py +++ b/tests/test_tool_approval_frontend_routing.py @@ -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") diff --git a/tests/test_tool_path_confinement.py b/tests/test_tool_path_confinement.py index be4a75162..d8e1400fc 100644 --- a/tests/test_tool_path_confinement.py +++ b/tests/test_tool_path_confinement.py @@ -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 diff --git a/tests/test_tool_routing_experiment.py b/tests/test_tool_routing_experiment.py index d09caf00e..25fcd03d2 100644 --- a/tests/test_tool_routing_experiment.py +++ b/tests/test_tool_routing_experiment.py @@ -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, )] diff --git a/tests/test_tool_task_cancelled_on_disconnect.py b/tests/test_tool_task_cancelled_on_disconnect.py index 46606d665..fde993a5d 100644 --- a/tests/test_tool_task_cancelled_on_disconnect.py +++ b/tests/test_tool_task_cancelled_on_disconnect.py @@ -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 diff --git a/tests/test_turn_contract.py b/tests/test_turn_contract.py index f2755538b..7e4b578f8 100644 --- a/tests/test_turn_contract.py +++ b/tests/test_turn_contract.py @@ -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") diff --git a/tests/test_upload_multifile.py b/tests/test_upload_multifile.py index 2e40948e6..cf692e610 100644 --- a/tests/test_upload_multifile.py +++ b/tests/test_upload_multifile.py @@ -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"] diff --git a/tests/test_web_search_raw_json_tool_call.py b/tests/test_web_search_raw_json_tool_call.py index 3c68c2ed2..09a5b57ba 100644 --- a/tests/test_web_search_raw_json_tool_call.py +++ b/tests/test_web_search_raw_json_tool_call.py @@ -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 = ( diff --git a/tests/test_web_search_time_filter.py b/tests/test_web_search_time_filter.py index 26c489fa4..88a42336a 100644 --- a/tests/test_web_search_time_filter.py +++ b/tests/test_web_search_time_filter.py @@ -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( diff --git a/tests/test_workspace_artifact_tool_floor.py b/tests/test_workspace_artifact_tool_floor.py index 20a5295e2..3de10155e 100644 --- a/tests/test_workspace_artifact_tool_floor.py +++ b/tests/test_workspace_artifact_tool_floor.py @@ -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):