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>
189 lines
6.8 KiB
Python
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
|