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
-
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):