Files
hermes-webui/tests/test_issue4490_presession_toolsets.py
T
nesquena-hermesandclaw-io 4e8e6714d5 fix(toolsets): only forward staged toolsets from empty composer + clear on session load (#4490 Codex follow-up)
Gate-found: staged _pendingSessionToolsets could leak into a later New Chat
started from an existing session (newSession forwarded it whenever it was an
array; loadSession never cleared it). Gate the forward on !S.session and clear
the staged value when a real session loads. +2 regression tests.

Co-authored-by: claw-io <claw-io@users.noreply.github.com>
2026-06-19 18:35:46 +00:00

189 lines
6.8 KiB
Python

"""Regression tests for #4490 pre-session toolset staging."""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from urllib.parse import urlparse
from unittest.mock import patch
from api.models import new_session
from api.routes import handle_post
REPO = Path(__file__).resolve().parents[1]
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
def _function_body(src: str, signature: str) -> str:
start = src.index(signature)
brace = src.index("{", start)
depth = 0
for i in range(brace, len(src)):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
if depth == 0:
return src[start : i + 1]
raise AssertionError(f"function body not found: {signature}")
class _DummyHandler:
command = "POST"
def __init__(self, body: dict):
raw = json.dumps(body).encode("utf-8")
self.headers = {"Content-Length": str(len(raw))}
self.rfile = tempfile.SpooledTemporaryFile()
self.rfile.write(raw)
self.rfile.seek(0)
self.status = None
self.response = {}
self.wfile = tempfile.SpooledTemporaryFile()
self.client_address = ("127.0.0.1", 12345)
def send_response(self, code: int):
self.status = code
def send_header(self, key: str, value: str):
self.response.setdefault("headers", {})[key] = value
def end_headers(self):
pass
def payload(self) -> dict:
self.wfile.seek(0)
return json.loads(self.wfile.read().decode("utf-8"))
def test_toggle_toolsets_dropdown_opens_without_session_guard():
toggle = _function_body(UI_JS, "function toggleToolsetsDropdown")
assert "!S.session" not in toggle
assert "chip.offsetParent === null" in toggle
assert "_populateToolsetsDropdown();" in toggle
def test_apply_toolsets_stages_without_session_and_skips_post():
apply_body = _function_body(UI_JS, "function _applySessionToolsets")
compact = apply_body.replace(" ", "")
assert "S._pendingSessionToolsets=toolsets" in compact
pre_session_branch = apply_body[: apply_body.index("const sid = S.session.session_id;")]
assert "api('/api/session/toolsets'" not in pre_session_branch
assert "_applyToolsetsChip(toolsets)" in pre_session_branch
def test_staged_toolsets_are_visibly_marked_on_chip():
apply_chip = _function_body(UI_JS, "function _applyToolsetsChip")
assert "(staged)" in apply_chip
assert "!S.session" in apply_chip
assert "S._pendingSessionToolsets" in apply_chip
def test_new_session_request_consumes_pending_toolsets_once():
compact = SESSIONS_JS.replace(" ", "")
post_start = compact.index("api('/api/session/new'")
before_post = compact[:post_start]
after_assignment = compact[compact.index("S.session=data.session") :]
assert "Array.isArray(S._pendingSessionToolsets)" in before_post
assert "reqBody.enabled_toolsets=S._pendingSessionToolsets" in before_post
assert "S._pendingSessionToolsets=null" in after_assignment
def test_pending_toolsets_only_forwarded_from_empty_composer():
"""The staged override must only be forwarded when there is no active session.
Without the `!S.session` guard, a toolset staged on the empty composer would
leak into a later New Chat started from an existing session (#4490 follow-up).
"""
compact = SESSIONS_JS.replace(" ", "")
post_start = compact.index("api('/api/session/new'")
before_post = compact[:post_start]
# The forwarding line must be gated on the no-session (empty composer) state.
assert "!S.session&&Array.isArray(S._pendingSessionToolsets)" in before_post
def test_load_existing_session_clears_staged_toolsets():
"""Loading a real existing session must clear any abandoned staged override.
loadSession() assigns S.session=data.session on the success path; the staged
value is cleared there so a subsequent New Chat does not inherit it (#4490).
"""
body = _function_body(SESSIONS_JS, "async function loadSession")
compact = body.replace(" ", "")
assign = compact.index("S.session=data.session")
# The clear must accompany the real-session assignment, not only the create path.
assert "S._pendingSessionToolsets=null" in compact[assign : assign + 400]
def test_workspace_and_profile_switches_clear_pending_toolsets():
for marker in (
"function promptWorkspacePath",
"function switchToWorkspace",
"function switchToProfile",
):
body = _function_body(PANELS_JS, marker)
assert "S._pendingSessionToolsets=null" in body.replace(" ", "")
assert "S._pendingSessionToolsets=null" in UI_JS.replace(" ", "")
def test_context_switch_auto_sessions_do_not_forward_staged_toolsets():
"""Workspace/profile context switches intentionally reset staged values.
The staged toolset belongs to the empty composer context. Auto-creating a
workspace/file session from another context should not silently carry it
forward; those paths clear the staged value after creating their session.
"""
for src, marker in (
(PANELS_JS, "function promptWorkspacePath"),
(PANELS_JS, "function switchToWorkspace"),
(UI_JS, "function promptNewFile"),
(UI_JS, "function promptNewFolder"),
):
body = _function_body(src, marker)
assert "enabled_toolsets" not in body
assert "S._pendingSessionToolsets=null" in body.replace(" ", "")
def test_backend_new_session_accepts_enabled_toolsets():
with tempfile.TemporaryDirectory() as tmp:
session = new_session(workspace=tmp, enabled_toolsets=["filesystem", "shell"])
assert session.enabled_toolsets == ["filesystem", "shell"]
def test_api_session_new_accepts_enabled_toolsets():
with tempfile.TemporaryDirectory() as tmp, patch(
"api.routes.get_last_workspace", return_value=tmp
):
handler = _DummyHandler({"enabled_toolsets": ["filesystem", "shell"]})
handle_post(handler, urlparse("/api/session/new"))
payload = handler.payload()
assert handler.status == 200
assert payload["session"]["enabled_toolsets"] == ["filesystem", "shell"]
def test_api_session_new_rejects_malformed_enabled_toolsets():
cases = [
{"enabled_toolsets": []},
{"enabled_toolsets": "filesystem"},
{"enabled_toolsets": ["filesystem", ""]},
{"enabled_toolsets": ["filesystem", 42]},
]
with tempfile.TemporaryDirectory() as tmp, patch(
"api.routes.get_last_workspace", return_value=tmp
):
for body in cases:
handler = _DummyHandler(body)
handle_post(handler, urlparse("/api/session/new"))
assert handler.status == 400