From dbba4978ee6080b121b8213d88ced9c5a5a43c1d Mon Sep 17 00:00:00 2001
From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:06:18 +0100
Subject: [PATCH 01/14] test(harness): support cache-busted frontend module
imports
---
.../test_settings_shell_coordinator.mjs | 3 +-
tests/streaming/markdownHarness.mjs | 2 +-
tests/test_copy_message_strips_thinking_js.py | 2 +-
tests/test_markdown_lazy_lib_loading_js.py | 2 +-
tests/test_markdown_rendering_js.py | 2 +-
tests/test_startup_session_bootstrap_js.py | 53 ++++++++++++++-----
...test_stream_completion_scroll_stability.py | 5 +-
7 files changed, 50 insertions(+), 19 deletions(-)
diff --git a/tests/helpers/test_settings_shell_coordinator.mjs b/tests/helpers/test_settings_shell_coordinator.mjs
index 79bf1fd84..458f0516a 100644
--- a/tests/helpers/test_settings_shell_coordinator.mjs
+++ b/tests/helpers/test_settings_shell_coordinator.mjs
@@ -847,9 +847,10 @@ function resolveImport(specifier, parent) {
throw new Error(`Unexpected non-relative import: ${specifier}`);
}
+ const cleanSpecifier = specifier.split('?')[0].split('#')[0];
return path.resolve(
path.dirname(parent),
- specifier,
+ cleanSpecifier,
);
}
diff --git a/tests/streaming/markdownHarness.mjs b/tests/streaming/markdownHarness.mjs
index 03e12fa61..af34b3372 100644
--- a/tests/streaming/markdownHarness.mjs
+++ b/tests/streaming/markdownHarness.mjs
@@ -26,7 +26,7 @@ export async function loadMarkdown() {
globalThis.MutationObserver = class { observe() {} };
let src = fs.readFileSync(path.join(REPO, 'static/js/markdown.js'), 'utf8');
- src = src.replace(/import uiModule from ['"]\.\/ui\.js['"];/, '');
+ src = src.replace(/import uiModule from ['"]\.\/ui\.js(?:\?[^'"]*)?['"];?/, '');
src = src.replace(
/import \{ splitTableRow \} from ['"]\.\/markdown\/tableRow\.js['"];/,
() => `function splitTableRow(row){return (row||'').replace(/^\\s*\\|/,'').replace(/\\|\\s*$/,'').split('|').map((c)=>c.trim());}`,
diff --git a/tests/test_copy_message_strips_thinking_js.py b/tests/test_copy_message_strips_thinking_js.py
index 4c912925c..74a03f80c 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_markdown_lazy_lib_loading_js.py b/tests/test_markdown_lazy_lib_loading_js.py
index e9d781caa..9a3b6e43b 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..2391f98ba 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_startup_session_bootstrap_js.py b/tests/test_startup_session_bootstrap_js.py
index 02e7be487..3fe37748b 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
From ebcc6c524f76246164e2efa03f3dbdca9555b7d5 Mon Sep 17 00:00:00 2001
From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:07:09 +0100
Subject: [PATCH 02/14] test(database): restore model endpoint isolation
---
tests/test_research_endpoint_owner_scope.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
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)
From 83088f88116ca6dbcc2874e40d08ce0934eb5c04 Mon Sep 17 00:00:00 2001
From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:08:16 +0100
Subject: [PATCH 03/14] ci: provision browser dependencies for pytest
---
.github/workflows/ci.yml | 9 ++++++++
tests/conftest.py | 50 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 59 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a276fdb1d..5e2231bc3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -140,6 +140,15 @@ 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
diff --git a/tests/conftest.py b/tests/conftest.py
index 2f6defa1c..0e8faebde 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -4,6 +4,7 @@ import os
import types
import importlib.util
from unittest.mock import MagicMock
+import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -93,3 +94,52 @@ def pytest_collection_modifyitems(config, items):
path = getattr(item, "path", None) or item.fspath
for marker_name in markers_for_path(path):
item.add_marker(getattr(pytest.mark, marker_name))
+
+
+@pytest.fixture(scope="session", autouse=True)
+def _serve_test_static():
+ """Ensure static assets are available on loopback port 7011 for browser integration tests."""
+ import socket
+ import threading
+ import http.server
+ import socketserver
+ from pathlib import Path
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ try:
+ is_bound = (sock.connect_ex(("127.0.0.1", 7011)) == 0)
+ finally:
+ sock.close()
+
+ if is_bound:
+ yield
+ return
+
+ root_dir = Path(__file__).resolve().parent.parent
+
+ class _Handler(http.server.SimpleHTTPRequestHandler):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, directory=str(root_dir), **kwargs)
+
+ def log_message(self, format, *args):
+ pass
+
+ def guess_type(self, path):
+ if path.endswith(".js") or path.endswith(".mjs"):
+ return "application/javascript"
+ if path.endswith(".css"):
+ return "text/css"
+ return super().guess_type(path)
+
+ class _Server(socketserver.TCPServer):
+ allow_reuse_address = True
+
+ try:
+ server = _Server(("127.0.0.1", 7011), _Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ yield
+ server.shutdown()
+ server.server_close()
+ except Exception:
+ yield
From 5568d7d631ced813c72aa85402de30684e305b1a Mon Sep 17 00:00:00 2001
From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:09:04 +0100
Subject: [PATCH 04/14] fix(sw): complete editor panel precache
---
static/sw.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/static/sw.js b/static/sw.js
index 97c35236c..9c8f67ae1 100644
--- a/static/sw.js
+++ b/static/sw.js
@@ -143,6 +143,8 @@ const PANEL_PRECACHE = [
'/static/js/editor/document-geometry.js',
'/static/js/editor/export-dialog.js',
'/static/js/editor/selection-mask.js',
+ '/static/js/editor/selection-modifiers.js',
+ '/static/js/editor/tool-shortcuts.js',
'/static/js/editor/filters/blur.js',
'/static/js/editor/filters/edge-feather.js',
'/static/js/editor/fx/adj-popup.js',
From 128102ef26858c44f8966260b02ff869a6b7e959 Mon Sep 17 00:00:00 2001
From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:17:42 +0100
Subject: [PATCH 05/14] test(runtime): isolate tool execution module state
---
tests/test_fenced_inline_args.py | 37 ++++++++++++++-----
tests/test_fenced_invoke_no_raw_xml.py | 37 ++++++++++++++-----
tests/test_web_search_raw_json_tool_call.py | 37 ++++++++++++++-----
tests/test_web_search_time_filter.py | 39 ++++++++++++++-------
4 files changed, 111 insertions(+), 39 deletions(-)
diff --git a/tests/test_fenced_inline_args.py b/tests/test_fenced_inline_args.py
index 0e9bd3c22..5de7d9a93 100644
--- a/tests/test_fenced_inline_args.py
+++ b/tests/test_fenced_inline_args.py
@@ -8,19 +8,38 @@ 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()
+# This module needs the real agent-tool stack; importing it pulls in heavy
+# DB/auth deps, so we stub those just long enough to import, then restore them.
+# We deliberately do NOT pop src.tool_execution: popping and re-importing it
+# rebinds the `src` package's `tool_execution` attribute, so a later
+# `import src.tool_execution as te` resolves to a different module object than
+# the one its functions live in - which silently breaks tests that monkeypatch
+# it (e.g. test_edit_file's admin gate) and breaks request-scoped ContextVars.
+_ABSENT = object()
+_AGENT_MODULES = ["src.agent_tools", "src.tool_parsing", "src.tool_schemas"]
+_STUBBED = [
+ "sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext", "sqlalchemy.ext.declarative",
+ "sqlalchemy.ext.hybrid", "sqlalchemy.sql", "sqlalchemy.sql.expression",
+ "src.database", "core.models", "core.database", "core.auth",
+]
+_saved_stubs = {name: sys.modules.get(name, _ABSENT) for name in _STUBBED}
+
+for _mod in _AGENT_MODULES:
+ sys.modules.pop(_mod, None)
+for _mod in _STUBBED:
+ 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
+# Drop the stubs we installed so they do not leak into later tests.
+for _name, _original in _saved_stubs.items():
+ if _original is _ABSENT:
+ sys.modules.pop(_name, None)
+ else:
+ sys.modules[_name] = _original
+
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..e8a66a76a 100644
--- a/tests/test_fenced_invoke_no_raw_xml.py
+++ b/tests/test_fenced_invoke_no_raw_xml.py
@@ -6,19 +6,38 @@ 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()
+# This module needs the real agent-tool stack; importing it pulls in heavy
+# DB/auth deps, so we stub those just long enough to import, then restore them.
+# We deliberately do NOT pop src.tool_execution: popping and re-importing it
+# rebinds the `src` package's `tool_execution` attribute, so a later
+# `import src.tool_execution as te` resolves to a different module object than
+# the one its functions live in - which silently breaks tests that monkeypatch
+# it (e.g. test_edit_file's admin gate) and breaks request-scoped ContextVars.
+_ABSENT = object()
+_AGENT_MODULES = ["src.agent_tools", "src.tool_parsing", "src.tool_schemas"]
+_STUBBED = [
+ "sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext", "sqlalchemy.ext.declarative",
+ "sqlalchemy.ext.hybrid", "sqlalchemy.sql", "sqlalchemy.sql.expression",
+ "src.database", "core.models", "core.database", "core.auth",
+]
+_saved_stubs = {name: sys.modules.get(name, _ABSENT) for name in _STUBBED}
+
+for _mod in _AGENT_MODULES:
+ sys.modules.pop(_mod, None)
+for _mod in _STUBBED:
+ 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
+# Drop the stubs we installed so they do not leak into later tests.
+for _name, _original in _saved_stubs.items():
+ if _original is _ABSENT:
+ sys.modules.pop(_name, None)
+ else:
+ sys.modules[_name] = _original
+
def test_unconvertible_invoke_in_fence_is_not_executed_as_code():
text = '```python\n\n1\n\n```'
diff --git a/tests/test_web_search_raw_json_tool_call.py b/tests/test_web_search_raw_json_tool_call.py
index 3c68c2ed2..57abc47d7 100644
--- a/tests/test_web_search_raw_json_tool_call.py
+++ b/tests/test_web_search_raw_json_tool_call.py
@@ -12,19 +12,38 @@ 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()
+# This module needs the real agent-tool stack; importing it pulls in heavy
+# DB/auth deps, so we stub those just long enough to import, then restore them.
+# We deliberately do NOT pop src.tool_execution: popping and re-importing it
+# rebinds the `src` package's `tool_execution` attribute, so a later
+# `import src.tool_execution as te` resolves to a different module object than
+# the one its functions live in - which silently breaks tests that monkeypatch
+# it (e.g. test_edit_file's admin gate) and breaks request-scoped ContextVars.
+_ABSENT = object()
+_AGENT_MODULES = ["src.agent_tools", "src.tool_parsing", "src.tool_schemas"]
+_STUBBED = [
+ "sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext", "sqlalchemy.ext.declarative",
+ "sqlalchemy.ext.hybrid", "sqlalchemy.sql", "sqlalchemy.sql.expression",
+ "src.database", "core.models", "core.database", "core.auth",
+]
+_saved_stubs = {name: sys.modules.get(name, _ABSENT) for name in _STUBBED}
+
+for _mod in _AGENT_MODULES:
+ sys.modules.pop(_mod, None)
+for _mod in _STUBBED:
+ 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
+# Drop the stubs we installed so they do not leak into later tests.
+for _name, _original in _saved_stubs.items():
+ if _original is _ABSENT:
+ sys.modules.pop(_name, None)
+ else:
+ sys.modules[_name] = _original
+
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..d54369413 100644
--- a/tests/test_web_search_time_filter.py
+++ b/tests/test_web_search_time_filter.py
@@ -9,25 +9,40 @@ 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)
+# This module needs the real agent-tool stack; importing it pulls in heavy
+# DB/auth deps, so we stub those just long enough to import, then restore them.
+# We deliberately do NOT pop src.tool_execution: popping and re-importing it
+# rebinds the `src` package's `tool_execution` attribute, so a later
+# `import src.tool_execution as te` resolves to a different module object than
+# the one its functions live in - which silently breaks tests that monkeypatch
+# it (e.g. test_edit_file's admin gate) and breaks request-scoped ContextVars.
+_ABSENT = object()
+_AGENT_MODULES = ["src.agent_tools", "src.tool_parsing", "src.tool_schemas"]
+_STUBBED = [
+ "sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext", "sqlalchemy.ext.declarative",
+ "sqlalchemy.ext.hybrid", "sqlalchemy.sql", "sqlalchemy.sql.expression",
+ "src.database", "core.models", "core.database", "core.auth",
+]
+_saved_stubs = {name: sys.modules.get(name, _ABSENT) for name in _STUBBED}
-# 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()
+for _mod in _AGENT_MODULES:
+ sys.modules.pop(_mod, None)
+for _mod in _STUBBED:
+ 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
+# Drop the stubs we installed so they do not leak into later tests.
+for _name, _original in _saved_stubs.items():
+ if _original is _ABSENT:
+ sys.modules.pop(_name, None)
+ else:
+ sys.modules[_name] = _original
+
def test_time_filter_is_preserved_as_json():
block = function_call_to_tool_block(
From 79a55fac382be4d96c00b3221db29b6dcf1c6441 Mon Sep 17 00:00:00 2001
From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Date: Tue, 22 Sep 2026 23:26:16 +0100
Subject: [PATCH 06/14] fix(agent): preserve focused turn contracts and
verified completion
---
routes/chat_routes.py | 9 ++++-
src/agent_loop.py | 40 +++++++++++++++----
src/turn_contract.py | 23 ++++++-----
tests/test_agent_evidence_loop.py | 3 +-
tests/test_agent_runtime_context.py | 4 +-
tests/test_chat_route_tool_policy.py | 17 ++++----
tests/test_clean_agent_preview.py | 8 ++--
tests/test_clean_v3_native_workspace.py | 3 +-
tests/test_foreground_model_routing.py | 6 +++
tests/test_minimal_native_tool_prompt.py | 8 +++-
tests/test_product_turn_contract_route.py | 9 +++--
tests/test_tool_routing_experiment.py | 2 +-
.../test_tool_task_cancelled_on_disconnect.py | 1 +
tests/test_turn_contract.py | 2 +-
14 files changed, 91 insertions(+), 44 deletions(-)
diff --git a/routes/chat_routes.py b/routes/chat_routes.py
index 6294d3adb..a7666021f 100644
--- a/routes/chat_routes.py
+++ b/routes/chat_routes.py
@@ -3502,8 +3502,13 @@ def setup_chat_routes(
# OCR operation. Exact operations therefore stay exact;
# ordinary native 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 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/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/tests/test_agent_evidence_loop.py b/tests/test_agent_evidence_loop.py
index fcf6e81b8..485efa78e 100644
--- a/tests/test_agent_evidence_loop.py
+++ b/tests/test_agent_evidence_loop.py
@@ -617,7 +617,6 @@ def test_finish_nudge_does_not_accept_unfinished_correction_promise(monkeypatch)
monkeypatch,
[
'```write_file\n/workspace/output.html\n
draft\n```',
- 'openfile:///workspace/output.html',
"The preview revealed 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_chat_route_tool_policy.py b/tests/test_chat_route_tool_policy.py
index 67ce7eaa7..4f796d143 100644
--- a/tests/test_chat_route_tool_policy.py
+++ b/tests/test_chat_route_tool_policy.py
@@ -284,23 +284,22 @@ def test_contextual_browser_followup_recognizes_current_page_inspection():
def test_clean_browser_filter_preserves_native_pdf_extraction_contract():
- source = _CHAT_ROUTES.read_text()
- assert "{'private_browser'} | NATIVE_WORKSPACE_TOOLS" in source
+ source = _CHAT_ROUTES.read_text(encoding="utf-8")
+ assert "INTERACTIVE_CORE_TOOLS" in source
+ assert "NATIVE_WORKSPACE_TOOLS" in source
+ assert "scope_preview_contract(" in source
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
+ assert "INTERACTIVE_CORE_TOOLS" in source
+ assert '{"private_browser"} if _local_browser_render_intent else frozenset()' in source
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
+ assert "INTERACTIVE_CORE_TOOLS" in source
+ assert "_exact_selected_native_chain" in source
def test_web_followup_grammar_covers_article_detail_questions():
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_foreground_model_routing.py b/tests/test_foreground_model_routing.py
index 02d6cfea6..5def63b48 100644
--- a/tests/test_foreground_model_routing.py
+++ b/tests/test_foreground_model_routing.py
@@ -2504,6 +2504,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 +2612,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 +3035,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 +3219,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 +3311,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 +3511,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_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_product_turn_contract_route.py b/tests/test_product_turn_contract_route.py
index d103ede73..998d2ea83 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
@@ -366,11 +368,12 @@ async def test_exact_odysseus_clean_route_offers_only_requested_compact_family(
async for _ in response.body_iterator:
pass
+ from src.clean_agent_preview import INTERACTIVE_CORE_TOOLS
assert len(observed) == 1
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"} | set(INTERACTIVE_CORE_TOOLS)
assert contract.required == {"manage_tasks"}
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")
From b9cccb93acefc2b0661738675b5c3b219e014956 Mon Sep 17 00:00:00 2001
From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Date: Tue, 22 Sep 2026 23:26:28 +0100
Subject: [PATCH 07/14] fix(tools): expose active Python environment in
workspace namespace
---
src/agent_tools/subprocess_tools.py | 23 ++++++++++++++++++++-
tests/test_tool_path_confinement.py | 2 +-
tests/test_workspace_artifact_tool_floor.py | 17 +++++++--------
3 files changed, 31 insertions(+), 11 deletions(-)
diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py
index 5b620cf5f..27ae30886 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,27 @@ 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)
+ mounted_roots = ("/usr", "/home", "/mnt")
+ if os.path.isdir(prefix) and not any(
+ prefix == root or prefix.startswith(root + os.sep)
+ for root in mounted_roots
+ ):
+ 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 +960,7 @@ class PythonTool:
python_command,
agent_cwd(),
chdir="/workspace",
+ interpreter_prefix=sys.prefix,
)
if needs_virtual_namespace
else None
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_workspace_artifact_tool_floor.py b/tests/test_workspace_artifact_tool_floor.py
index 20a5295e2..1d65ea9e3 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,7 +2190,7 @@ 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
@@ -2201,7 +2202,7 @@ 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
script = workspace / ".python-workspace-alias-test.py"
output = workspace / ".python-workspace-alias-test.txt"
script.write_text(
@@ -2209,13 +2210,11 @@ def test_python_loaded_code_sees_virtual_workspace_alias(monkeypatch):
)
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_preserves_the_64_bit_dynamic_loader(monkeypatch):
From 4520b4f6f009a63be7bc65055a04ed7c4a7ea4b0 Mon Sep 17 00:00:00 2001
From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Date: Tue, 22 Sep 2026 23:26:43 +0100
Subject: [PATCH 08/14] fix(ui): restore rich text controls and accessible
research map
---
static/js/document.js | 10 ++++++++++
static/js/researchSynapse.js | 2 +-
static/style.css | 3 +--
tests/test_compare_ask_user_routing.py | 8 ++++----
.../test_document_rich_font_color_controls.py | 10 +++++++---
.../test_document_rich_keyboard_shortcuts.py | 3 ++-
tests/test_document_rich_slash_menu.py | 11 +++++++---
tests/test_document_rich_toolbar_menus.py | 10 +++++++---
tests/test_document_toolbar_order.py | 20 +++++++++----------
tests/test_escape_inner_layers.py | 13 +++++++-----
tests/test_research_synapse_ui.py | 4 ++--
11 files changed, 60 insertions(+), 34 deletions(-)
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
-