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

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