onSearchChange(event.target.value)}
placeholder=${t("projects.searchPlaceholder")}
@@ -189,6 +187,13 @@ export function ProjectsGrid({
? html`
${scopedProjects.map((project) => html`<${ProjectCard} key=${project.id} project=${project} onOpen=${onOpenProject} t=${t} />`)}
`
+ : !projects.length
+ ? html`
+ <${EmptyPanel}
+ title=${t("projects.empty.noMatchTitle")}
+ description=${t("projects.empty.noMatchDesc")}
+ />
+ `
: html`
<${EmptyPanel}
title=${t("projects.scoped.onlyGeneralTitle")}
diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/projects/lib/projects-api.js b/crates/ironclaw_webui_v2_static/static/js/pages/projects/lib/projects-api.js
index 504ac054086..b5d434e5e39 100644
--- a/crates/ironclaw_webui_v2_static/static/js/pages/projects/lib/projects-api.js
+++ b/crates/ironclaw_webui_v2_static/static/js/pages/projects/lib/projects-api.js
@@ -1,7 +1,7 @@
// Project endpoints now call the real WebChat v2 `/api/webchat/v2/projects`
-// surface (list/create/read/update/delete + membership ACL). Mission/thread/
-// widget reads remain TODO stubs until the v2 missions/threads-by-project
-// endpoints land — the page degrades to empty panels for those.
+// surface (list/create/read/update/delete + membership ACL) plus the v2
+// project-filtered thread list. Mission/widget reads remain TODO stubs until
+// those v2 project child endpoints land.
import {
addProjectMember as apiAddProjectMember,
@@ -13,6 +13,7 @@ import {
removeProjectMember as apiRemoveProjectMember,
updateProject as apiUpdateProject,
updateProjectMemberRole as apiUpdateProjectMemberRole,
+ listThreads as apiListThreads,
} from "../../../lib/api.js";
// Map a wire `RebornProjectInfo` to the shape the Projects page components
@@ -46,6 +47,17 @@ function toPageProject(project) {
};
}
+function toPageThread(thread) {
+ if (!thread) return null;
+ return {
+ ...thread,
+ id: thread.thread_id,
+ state: thread.state || null,
+ turn_count: thread.turn_count || 0,
+ updated_at: thread.updated_at || null,
+ };
+}
+
export async function fetchProjectsOverview() {
const response = await apiListProjects({ limit: 200 });
const projects = (response?.projects || []).map(toPageProject);
@@ -94,8 +106,13 @@ export function removeProjectMember(projectId, userId) {
export function fetchProjectMissions(_projectId) {
return Promise.resolve({ missions: [], todo: true });
}
-export function fetchProjectThreads(_projectId) {
- return Promise.resolve({ threads: [], todo: true });
+export async function fetchProjectThreads(projectId) {
+ if (!projectId) return { threads: [] };
+ const response = await apiListThreads({ projectId, limit: 200 });
+ return {
+ threads: (response?.threads || []).map(toPageThread).filter(Boolean),
+ next_cursor: response?.next_cursor || null,
+ };
}
export function fetchProjectWidgets(_projectId) {
return Promise.resolve({ widgets: [], todo: true });
diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/projects/projects-page.js b/crates/ironclaw_webui_v2_static/static/js/pages/projects/projects-page.js
index f82dd284e6f..ea62d64c4c9 100644
--- a/crates/ironclaw_webui_v2_static/static/js/pages/projects/projects-page.js
+++ b/crates/ironclaw_webui_v2_static/static/js/pages/projects/projects-page.js
@@ -64,15 +64,15 @@ export function ProjectsPage() {
type: "error",
message: error.message || t("projects.chatAutoFail"),
});
+ return;
}
- navigate("/chat", {
+ navigate(nextThreadId ? `/chat/${nextThreadId}` : "/chat", {
state: {
composerDraft: t("projects.creationDraft"),
- threadId: nextThreadId,
},
});
- }, [navigate, threadsState]);
+ }, [navigate, threadsState, t]);
const handleOpenThread = React.useCallback((nextThreadId) => {
navigate(`/projects/${projectId}/threads/${nextThreadId}`);
@@ -85,7 +85,7 @@ export function ProjectsPage() {
setChatFlowError(null);
try {
const newThreadId = await threadsState.createThread(projectId);
- navigate("/chat", { state: { threadId: newThreadId } });
+ navigate(newThreadId ? `/chat/${newThreadId}` : "/chat");
workspaceState.invalidate();
} catch (error) {
setChatFlowError({
diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/channels-tab.js b/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/channels-tab.js
index 33f0913d2f9..379ebcff663 100644
--- a/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/channels-tab.js
+++ b/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/channels-tab.js
@@ -35,6 +35,7 @@ function ExtensionChannelCard({ channel, registryEntry }) {
const t = useT();
const name =
registryEntry?.display_name ||
+ channel?.display_name ||
channel?.name ||
registryEntry?.name ||
t("common.unknown");
diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/tools-tab.js b/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/tools-tab.js
index d42b8992fba..00a1b019fb9 100644
--- a/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/tools-tab.js
+++ b/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/tools-tab.js
@@ -107,14 +107,18 @@ function ToolRow({ tool, onPermissionChange, isSaved }) {
return html`
${isLocked &&
- html`<${Icon}
- name="lock"
- className="h-3.5 w-3.5 shrink-0 text-[var(--v2-text-faint)]"
- />`}
+ html`
+ <${Icon}
+ name="lock"
+ className="h-3.5 w-3.5 text-[var(--v2-text-faint)]"
+ />
+ `}
{
@@ -98,6 +109,25 @@ export function SettingsPage() {
`}
+ ${importError &&
+ html`
+
+ ${t("settings.importFailed", { message: importError.message })}
+
+ `}
+
+ <${SettingsToolbar}
+ settingsExport=${query.data || null}
+ onImport=${importSettings}
+ isImporting=${isImporting}
+ searchQuery=${searchQuery}
+ onSearchChange=${setSearchQuery}
+ onSearchClear=${() => setSearchQuery("")}
+ canGoBack=${false}
+ />
+
${tabContent[tab]}
diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py
index cef7e82f9e5..f630aa4c931 100644
--- a/tests/e2e/helpers.py
+++ b/tests/e2e/helpers.py
@@ -306,6 +306,18 @@
"logs_entry_message": "[data-testid='logs-entry-message']",
"logs_entry_context": "[data-testid='logs-entry-context']",
"logs_context_chip": "[data-testid='logs-context-chip'][data-context-key='{key}']",
+ "settings_search_placeholder": "Search settings...",
+ "settings_tool_row_for": (
+ "[data-testid='settings-tool-row'][data-tool-name='{name}']"
+ ),
+ "settings_tool_lock": "[data-testid='settings-tool-lock']",
+ "llm_provider_card_for": (
+ "[data-testid='llm-provider-card'][data-provider-id='{provider_id}']"
+ ),
+ "llm_provider_disclosure": "llm-provider-disclosure",
+ "skills_card": "#skills-list .ext-card",
+ "skill_name_placeholder": "skill-name",
+ "skill_content_placeholder": "---\\nname: example\\ndescription: ...\\n---\\n",
}
diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_projects.py b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_projects.py
new file mode 100644
index 00000000000..15341312792
--- /dev/null
+++ b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_projects.py
@@ -0,0 +1,412 @@
+"""Legacy project overview coverage ported to Reborn WebChat v2."""
+
+import json
+from pathlib import Path
+from urllib.parse import parse_qs, unquote, urlparse
+
+from playwright.async_api import expect
+
+from helpers import REBORN_V2_AUTH_TOKEN, SEL_V2
+from reborn_webui_harness import (
+ reborn_v2_browser, # noqa: F401 - imported fixture
+ reborn_v2_server, # noqa: F401 - imported fixture
+)
+
+
+MOCK_PROJECT_ID = "068f67da-49b6-4f6c-9463-8d243c2cff6c"
+PRODUCT_PROJECT_ID = "b1234567-cafe-4000-a000-111111111111"
+PROJECT_THREAD_ID = "thread-project-files"
+PROJECT_WORKSPACE_FILE_BYTES = b"# Launch Brief\n\nShip the research digest.\n"
+
+MOCK_PROJECTS = [
+ {
+ "project_id": "default",
+ "name": "default",
+ "description": "",
+ "metadata": {},
+ "state": "active",
+ "role": "owner",
+ "created_at": "2026-04-12T08:00:00Z",
+ "updated_at": "2026-04-12T10:30:00Z",
+ },
+ {
+ "project_id": MOCK_PROJECT_ID,
+ "name": "AI Research Intelligence",
+ "description": (
+ "Stay informed on the latest AI research with daily paper digests "
+ "and weekly trend analysis."
+ ),
+ "metadata": {
+ "goals": [
+ "Monitor arXiv AI papers daily",
+ "Generate weekly trend synthesis reports",
+ ]
+ },
+ "state": "active",
+ "role": "owner",
+ "created_at": "2026-04-12T08:45:00Z",
+ "updated_at": "2026-04-12T09:15:00Z",
+ },
+ {
+ "project_id": PRODUCT_PROJECT_ID,
+ "name": "Product Launch Q2",
+ "description": (
+ "Coordinate the Q2 product launch campaign across marketing, "
+ "engineering, and sales."
+ ),
+ "metadata": {"goals": ["Ship v2.0 by June 15", "Hit launch signups"]},
+ "state": "active",
+ "role": "owner",
+ "created_at": "2026-04-11T08:45:00Z",
+ "updated_at": "2026-04-12T08:45:00Z",
+ },
+]
+
+
+async def _open_mocked_projects_page(reborn_v2_server, reborn_v2_browser):
+ context = await reborn_v2_browser.new_context(
+ viewport={"width": 1280, "height": 720}
+ )
+ try:
+ page = await context.new_page()
+ project_requests: list[str] = []
+ thread_create_requests: list[dict] = []
+ project_thread_requests: list[str] = []
+ project_file_requests: list[str] = []
+
+ async def fulfill_json(route, payload, status=200):
+ await route.fulfill(
+ status=status,
+ content_type="application/json",
+ body=json.dumps(payload),
+ headers={"Cache-Control": "no-store"},
+ )
+
+ async def handle_projects(route):
+ request = route.request
+ parsed = urlparse(request.url)
+ path = parsed.path
+
+ if path == "/api/webchat/v2/projects" and request.method == "GET":
+ project_requests.append(path)
+ await fulfill_json(route, {"projects": MOCK_PROJECTS})
+ return
+
+ prefix = "/api/webchat/v2/projects/"
+ if path.startswith(prefix) and request.method == "GET":
+ project_id = unquote(path.removeprefix(prefix))
+ project_requests.append(path)
+ project = next(
+ (
+ candidate
+ for candidate in MOCK_PROJECTS
+ if candidate["project_id"] == project_id
+ ),
+ None,
+ )
+ await fulfill_json(
+ route,
+ {"project": project},
+ status=200 if project is not None else 404,
+ )
+ return
+
+ await route.continue_()
+
+ async def handle_threads(route):
+ request = route.request
+ parsed = urlparse(request.url)
+ path = parsed.path
+
+ if path == "/api/webchat/v2/threads" and request.method == "GET":
+ query = parse_qs(parsed.query)
+ if query.get("project_id") == [MOCK_PROJECT_ID]:
+ project_thread_requests.append(request.url)
+ await fulfill_json(
+ route,
+ {
+ "threads": [
+ {
+ "thread_id": PROJECT_THREAD_ID,
+ "title": "Weekly research digest",
+ "goal": "Summarize launch-readiness signals.",
+ "thread_type": "chat",
+ "project_id": MOCK_PROJECT_ID,
+ "created_at": "2026-04-12T11:30:00Z",
+ "updated_at": "2026-04-12T12:00:00Z",
+ }
+ ],
+ "next_cursor": None,
+ },
+ )
+ return
+ await fulfill_json(route, {"threads": [], "next_cursor": None})
+ return
+
+ if path == "/api/webchat/v2/threads" and request.method == "POST":
+ body = json.loads(request.post_data or "{}")
+ thread_create_requests.append(body)
+ await fulfill_json(
+ route,
+ {
+ "thread": {
+ "thread_id": "thread-project-scoped",
+ "title": "Project scoped conversation",
+ "project_id": body.get("project_id"),
+ "created_at": "2026-04-12T11:00:00Z",
+ "updated_at": "2026-04-12T11:00:00Z",
+ }
+ },
+ )
+ return
+
+ if path == "/api/webchat/v2/threads/thread-project-scoped/timeline":
+ await fulfill_json(route, {"messages": [], "next_cursor": None})
+ return
+
+ if path == f"/api/webchat/v2/threads/{PROJECT_THREAD_ID}/files":
+ project_file_requests.append(request.url)
+ query = parse_qs(parsed.query)
+ if query.get("path") == ["/workspace/reports"]:
+ await fulfill_json(
+ route,
+ {
+ "entries": [
+ {
+ "name": "launch-brief.md",
+ "path": "/workspace/reports/launch-brief.md",
+ "kind": "file",
+ "size": len(PROJECT_WORKSPACE_FILE_BYTES),
+ }
+ ]
+ },
+ )
+ return
+
+ await fulfill_json(
+ route,
+ {
+ "entries": [
+ {
+ "name": "reports",
+ "path": "/workspace/reports",
+ "kind": "directory",
+ },
+ {
+ "name": "README.md",
+ "path": "/workspace/README.md",
+ "kind": "file",
+ "size": 42,
+ },
+ ]
+ },
+ )
+ return
+
+ if path == f"/api/webchat/v2/threads/{PROJECT_THREAD_ID}/files/content":
+ project_file_requests.append(request.url)
+ await route.fulfill(
+ status=200,
+ content_type="text/markdown",
+ body=PROJECT_WORKSPACE_FILE_BYTES.decode("utf-8"),
+ headers={"Cache-Control": "no-store"},
+ )
+ return
+
+ await route.continue_()
+
+ await page.route("**/api/webchat/v2/projects**", handle_projects)
+ await page.route("**/api/webchat/v2/threads**", handle_threads)
+ await page.goto(
+ f"{reborn_v2_server}/v2/projects?token={REBORN_V2_AUTH_TOKEN}"
+ )
+
+ try:
+ await expect(page.locator(SEL_V2["projects_grid"])).to_be_visible(
+ timeout=15000
+ )
+ except AssertionError as error:
+ body_text = await page.locator("body").inner_text(timeout=1000)
+ raise AssertionError(
+ f"Projects grid did not render on {page.url}.\nBody text:\n{body_text}"
+ ) from error
+
+ return {
+ "context": context,
+ "page": page,
+ "project_requests": project_requests,
+ "thread_create_requests": thread_create_requests,
+ "project_thread_requests": project_thread_requests,
+ "project_file_requests": project_file_requests,
+ }
+ except Exception:
+ await context.close()
+ raise
+
+
+async def test_reborn_legacy_projects_overview_search_and_open_workspace(
+ reborn_v2_server, reborn_v2_browser
+):
+ harness = await _open_mocked_projects_page(reborn_v2_server, reborn_v2_browser)
+ try:
+ page = harness["page"]
+ project_requests = harness["project_requests"]
+
+ default_card = page.locator(SEL_V2["project_card_for"].format(id="default"))
+ research_card = page.locator(
+ SEL_V2["project_card_for"].format(id=MOCK_PROJECT_ID)
+ )
+ product_card = page.locator(
+ SEL_V2["project_card_for"].format(id=PRODUCT_PROJECT_ID)
+ )
+
+ await expect(default_card).to_be_visible(timeout=5000)
+ await expect(research_card).to_contain_text("AI Research Intelligence")
+ await expect(research_card).to_contain_text("weekly trend analysis")
+ await expect(research_card).to_contain_text("Monitor arXiv AI papers daily")
+ await expect(product_card).to_contain_text("Product Launch Q2")
+
+ search = page.locator(SEL_V2["projects_search_input"])
+ await search.fill("trend synthesis")
+ await expect(research_card).to_be_visible()
+ await expect(product_card).to_have_count(0)
+ await expect(default_card).to_have_count(0)
+
+ await search.fill("")
+ research_card = page.locator(
+ SEL_V2["project_card_for"].format(id=MOCK_PROJECT_ID)
+ )
+ await research_card.locator(SEL_V2["project_open_workspace"]).click()
+
+ await expect(
+ page.locator(SEL_V2["project_workspace_for"].format(id=MOCK_PROJECT_ID))
+ ).to_be_visible(timeout=10000)
+ await expect(page.locator(SEL_V2["project_workspace_title"])).to_have_text(
+ "AI Research Intelligence"
+ )
+ await page.wait_for_url(f"**/v2/projects/{MOCK_PROJECT_ID}**", timeout=5000)
+ assert "/api/webchat/v2/projects" in project_requests
+ assert f"/api/webchat/v2/projects/{MOCK_PROJECT_ID}" in project_requests
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_projects_search_no_match_can_be_cleared(
+ reborn_v2_server, reborn_v2_browser
+):
+ harness = await _open_mocked_projects_page(reborn_v2_server, reborn_v2_browser)
+ try:
+ page = harness["page"]
+ search = page.locator(SEL_V2["projects_search_input"])
+
+ await search.fill("no-project-matches-this")
+ await expect(
+ page.get_by_text("No projects match the current search")
+ ).to_be_visible(timeout=5000)
+ await expect(search).to_be_visible()
+ await expect(
+ page.locator(SEL_V2["project_card_for"].format(id=MOCK_PROJECT_ID))
+ ).to_have_count(0)
+
+ await search.fill("")
+ await expect(
+ page.locator(SEL_V2["project_card_for"].format(id=MOCK_PROJECT_ID))
+ ).to_be_visible(timeout=5000)
+ await expect(
+ page.locator(SEL_V2["project_card_for"].format(id=PRODUCT_PROJECT_ID))
+ ).to_be_visible()
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_project_creation_opens_seeded_chat_thread(
+ reborn_v2_server, reborn_v2_browser
+):
+ harness = await _open_mocked_projects_page(reborn_v2_server, reborn_v2_browser)
+ try:
+ page = harness["page"]
+
+ await page.get_by_role("button", name="New project").click()
+ await page.wait_for_url("**/v2/chat/thread-project-scoped", timeout=10000)
+
+ composer = page.locator(SEL_V2["chat_composer"])
+ await expect(composer).to_be_visible(timeout=10000)
+ await expect(composer).to_have_value(
+ "Create a new project for me. I want to set up a project for: ",
+ timeout=5000,
+ )
+
+ assert len(harness["thread_create_requests"]) == 1
+ assert "project_id" not in harness["thread_create_requests"][0]
+ assert harness["thread_create_requests"][0]["client_action_id"]
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_project_workspace_starts_scoped_chat_thread(
+ reborn_v2_server, reborn_v2_browser
+):
+ harness = await _open_mocked_projects_page(reborn_v2_server, reborn_v2_browser)
+ try:
+ page = harness["page"]
+
+ await page.locator(
+ SEL_V2["project_card_for"].format(id=MOCK_PROJECT_ID)
+ ).locator(SEL_V2["project_open_workspace"]).click()
+ await expect(
+ page.locator(SEL_V2["project_workspace_for"].format(id=MOCK_PROJECT_ID))
+ ).to_be_visible(timeout=10000)
+
+ await page.get_by_role("button", name="New conversation").click()
+ await page.wait_for_url("**/v2/chat/thread-project-scoped", timeout=10000)
+ await expect(page.locator(SEL_V2["chat_composer"])).to_be_visible(timeout=10000)
+
+ assert len(harness["thread_create_requests"]) == 1
+ assert harness["thread_create_requests"][0]["project_id"] == MOCK_PROJECT_ID
+ assert harness["thread_create_requests"][0]["client_action_id"]
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_project_workspace_lists_and_downloads_files(
+ reborn_v2_server, reborn_v2_browser
+):
+ harness = await _open_mocked_projects_page(reborn_v2_server, reborn_v2_browser)
+ try:
+ page = harness["page"]
+
+ await page.locator(
+ SEL_V2["project_card_for"].format(id=MOCK_PROJECT_ID)
+ ).locator(SEL_V2["project_open_workspace"]).click()
+ await expect(
+ page.locator(SEL_V2["project_workspace_for"].format(id=MOCK_PROJECT_ID))
+ ).to_be_visible(timeout=10000)
+
+ await expect(page.get_by_text("Weekly research digest")).to_be_visible(
+ timeout=10000
+ )
+ reports_entry = page.locator(
+ SEL_V2["project_filesystem_entry_for"].format(path="/workspace/reports")
+ )
+ await expect(reports_entry).to_be_visible(timeout=10000)
+ await reports_entry.click()
+
+ launch_brief_entry = page.locator(
+ SEL_V2["project_filesystem_entry_for"].format(
+ path="/workspace/reports/launch-brief.md"
+ )
+ )
+ await expect(launch_brief_entry).to_be_visible(timeout=10000)
+
+ async with page.expect_download() as download_info:
+ await launch_brief_entry.click()
+ download = await download_info.value
+ assert download.suggested_filename == "launch-brief.md"
+ assert Path(await download.path()).read_bytes() == PROJECT_WORKSPACE_FILE_BYTES
+
+ assert harness["project_thread_requests"]
+ assert any("project_id=" in url for url in harness["project_thread_requests"])
+ assert any("/files" in url for url in harness["project_file_requests"])
+ assert any("/files/content" in url for url in harness["project_file_requests"])
+ finally:
+ await harness["context"].close()
diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_settings_search.py b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_settings_search.py
new file mode 100644
index 00000000000..a1982bdbf33
--- /dev/null
+++ b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_settings_search.py
@@ -0,0 +1,606 @@
+"""Legacy settings search coverage ported to Reborn WebChat v2."""
+
+import json
+from urllib.parse import urlparse
+
+from playwright.async_api import expect
+
+from helpers import REBORN_V2_AUTH_TOKEN, SEL_V2
+from reborn_webui_harness import (
+ reborn_v2_browser, # noqa: F401 - imported fixture
+ reborn_v2_server, # noqa: F401 - imported fixture
+)
+
+
+MOCK_TOOL_ENTRIES = [
+ {
+ "key": "agent.auto_approve_tools",
+ "value": False,
+ "mutable": True,
+ "source": "default",
+ },
+ {
+ "key": "tool.echo",
+ "value": {
+ "name": "echo",
+ "description": "Echo text back for deterministic tests.",
+ "state": "ask_each_time",
+ "default_state": "ask_each_time",
+ "effective_source": "default",
+ },
+ "mutable": True,
+ "source": "default",
+ },
+ {
+ "key": "tool.search_web",
+ "value": {
+ "name": "search_web",
+ "description": "Search the web for current answers.",
+ "state": "always_allow",
+ "default_state": "ask_each_time",
+ "effective_source": "override",
+ },
+ "mutable": True,
+ "source": "override",
+ },
+]
+
+MOCK_SKILLS = [
+ {
+ "name": "markdown-helper",
+ "description": "Formats markdown for deterministic tests.",
+ "version": "1.0.0",
+ "trust": "Installed",
+ "source_kind": "installed",
+ "keywords": ["markdown"],
+ "usage_hint": "Use for markdown formatting.",
+ "can_edit": True,
+ "can_delete": True,
+ "auto_activate": True,
+ },
+ {
+ "name": "workspace-helper",
+ "description": "Reads workspace context.",
+ "version": "1.0.0",
+ "trust": "Trusted",
+ "source_kind": "workspace",
+ "keywords": ["workspace"],
+ "can_edit": False,
+ "can_delete": False,
+ },
+]
+
+MOCK_CHANNEL_EXTENSION = {
+ "name": "telegram-channel",
+ "package_ref": {"kind": "extension", "id": "telegram-channel"},
+ "display_name": "Telegram Channel",
+ "kind": "wasm_channel",
+ "description": "Configured messaging channel.",
+ "active": True,
+ "authenticated": True,
+ "onboarding_state": "ready",
+}
+
+MOCK_MCP_EXTENSION = {
+ "name": "beta-mcp",
+ "package_ref": {"kind": "extension", "id": "beta-mcp"},
+ "display_name": "Beta MCP",
+ "kind": "mcp_server",
+ "description": "Installed MCP server.",
+ "active": False,
+ "authenticated": False,
+}
+
+
+async def _open_mocked_settings_page(
+ reborn_v2_server,
+ reborn_v2_browser,
+ *,
+ tab: str,
+ llm_state: dict | None = None,
+ llm_requests: list[dict] | None = None,
+):
+ context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720})
+ page = await context.new_page()
+ browser_messages: list[str] = []
+ page.on(
+ "console",
+ lambda message: browser_messages.append(f"{message.type}: {message.text}"),
+ )
+ page.on("pageerror", lambda error: browser_messages.append(f"pageerror: {error}"))
+
+ async def fulfill_json(route, payload, status=200):
+ await route.fulfill(
+ status=status,
+ content_type="application/json",
+ body=json.dumps(payload),
+ headers={"Cache-Control": "no-store"},
+ )
+
+ async def handle_session(route):
+ request = route.request
+ path = urlparse(request.url).path
+
+ if path == "/api/webchat/v2/session" and request.method == "GET":
+ await fulfill_json(
+ route,
+ {
+ "tenant_id": "reborn-v2-e2e",
+ "user_id": "reborn-v2-e2e-user",
+ "capabilities": {"operator_webui_config": True},
+ "features": {"reborn_projects": False},
+ },
+ )
+ return
+
+ await route.continue_()
+
+ async def handle_settings_tools(route):
+ request = route.request
+ path = urlparse(request.url).path
+
+ if path == "/api/webchat/v2/settings/tools" and request.method == "GET":
+ await fulfill_json(route, {"entries": MOCK_TOOL_ENTRIES})
+ return
+
+ await route.continue_()
+
+ async def handle_skills(route):
+ request = route.request
+ path = urlparse(request.url).path
+
+ if path == "/api/webchat/v2/skills" and request.method == "GET":
+ await fulfill_json(
+ route,
+ {
+ "skills": MOCK_SKILLS,
+ "count": len(MOCK_SKILLS),
+ "auto_activate_learned": True,
+ },
+ )
+ return
+
+ await route.continue_()
+
+ async def handle_extensions(route):
+ request = route.request
+ path = urlparse(request.url).path
+
+ if path == "/api/webchat/v2/extensions" and request.method == "GET":
+ await fulfill_json(
+ route,
+ {"extensions": [MOCK_CHANNEL_EXTENSION, MOCK_MCP_EXTENSION]},
+ )
+ return
+
+ if path == "/api/webchat/v2/extensions/registry" and request.method == "GET":
+ await fulfill_json(route, {"entries": []})
+ return
+
+ await route.continue_()
+
+ async def handle_llm(route):
+ request = route.request
+ path = urlparse(request.url).path
+ method = request.method
+
+ if llm_state is None:
+ await route.continue_()
+ return
+
+ def record(kind: str, payload: dict | None = None) -> None:
+ if llm_requests is not None:
+ llm_requests.append({"kind": kind, "payload": payload or {}})
+
+ def request_json() -> dict:
+ raw = request.post_data or "{}"
+ return json.loads(raw)
+
+ def provider_from_payload(payload: dict) -> dict:
+ existing = next(
+ (
+ provider
+ for provider in llm_state["providers"]
+ if provider["id"] == payload["id"]
+ ),
+ {},
+ )
+ return {
+ **existing,
+ "id": payload["id"],
+ "description": payload.get("name") or payload["id"],
+ "adapter": payload.get("adapter") or existing.get("adapter"),
+ "base_url": payload.get("base_url", existing.get("base_url", "")),
+ "default_model": payload.get(
+ "default_model", existing.get("default_model", "")
+ ),
+ "builtin": False,
+ "api_key_set": bool(payload.get("api_key"))
+ or existing.get("api_key_set", False),
+ "api_key_required": payload.get("adapter") != "ollama",
+ "base_url_required": True,
+ "accepts_api_key": payload.get("adapter") != "ollama",
+ }
+
+ if path == "/api/webchat/v2/llm/providers" and method == "GET":
+ await fulfill_json(
+ route,
+ {
+ "providers": llm_state["providers"],
+ "active": llm_state.get("active"),
+ },
+ )
+ return
+
+ if path == "/api/webchat/v2/llm/providers" and method == "POST":
+ payload = request_json()
+ record("upsert", payload)
+ provider = provider_from_payload(payload)
+ llm_state["providers"] = [
+ item for item in llm_state["providers"] if item["id"] != provider["id"]
+ ] + [provider]
+ if payload.get("set_active"):
+ llm_state["active"] = {
+ "provider_id": provider["id"],
+ "model": payload.get("model") or provider.get("default_model"),
+ }
+ await fulfill_json(route, {"provider": provider})
+ return
+
+ if path == "/api/webchat/v2/llm/active" and method == "POST":
+ payload = request_json()
+ record("active", payload)
+ llm_state["active"] = {
+ "provider_id": payload["provider_id"],
+ "model": payload["model"],
+ }
+ await fulfill_json(route, {"active": llm_state["active"]})
+ return
+
+ if path == "/api/webchat/v2/llm/list-models" and method == "POST":
+ payload = request_json()
+ record("list_models", payload)
+ await fulfill_json(
+ route,
+ {
+ "ok": True,
+ "models": ["acme-fast", "acme-pro"],
+ "message": "models listed",
+ },
+ )
+ return
+
+ if path == "/api/webchat/v2/llm/test-connection" and method == "POST":
+ payload = request_json()
+ record("test_connection", payload)
+ await fulfill_json(
+ route,
+ {"ok": True, "message": f"probe ok for {payload.get('model')}"},
+ )
+ return
+
+ if path.startswith("/api/webchat/v2/llm/providers/") and path.endswith(
+ "/delete"
+ ) and method == "POST":
+ provider_id = (
+ path.removeprefix("/api/webchat/v2/llm/providers/")
+ .removesuffix("/delete")
+ )
+ record("delete", {"provider_id": provider_id})
+ llm_state["providers"] = [
+ provider
+ for provider in llm_state["providers"]
+ if provider["id"] != provider_id
+ ]
+ await fulfill_json(route, {"success": True})
+ return
+
+ await route.continue_()
+
+ await page.route("**/api/webchat/v2/session", handle_session)
+ await page.route("**/api/webchat/v2/settings/tools**", handle_settings_tools)
+ await page.route("**/api/webchat/v2/skills**", handle_skills)
+ await page.route("**/api/webchat/v2/extensions**", handle_extensions)
+ await page.route("**/api/webchat/v2/llm/**", handle_llm)
+
+ await page.goto(f"{reborn_v2_server}/v2/settings/{tab}?token={REBORN_V2_AUTH_TOKEN}")
+ search = page.get_by_placeholder(SEL_V2["settings_search_placeholder"])
+ try:
+ await expect(search).to_be_visible(timeout=15000)
+ except AssertionError as error:
+ body_text = await page.locator("body").inner_text(timeout=1000)
+ raise AssertionError(
+ f"Settings search toolbar did not render on {page.url}.\n"
+ f"Browser messages: {browser_messages}\n"
+ f"Body text:\n{body_text}"
+ ) from error
+
+ return {"context": context, "page": page, "search": search}
+
+
+def _provider_card(page, provider_id: str):
+ return page.locator(
+ SEL_V2["llm_provider_card_for"].format(provider_id=provider_id)
+ )
+
+
+def _mock_llm_state() -> dict:
+ return {
+ "active": {"provider_id": "openai", "model": "gpt-4.1-mini"},
+ "providers": [
+ {
+ "id": "openai",
+ "description": "OpenAI API",
+ "adapter": "open_ai_completions",
+ "base_url": "https://api.openai.test/v1",
+ "default_model": "gpt-4.1-mini",
+ "builtin": True,
+ "api_key_set": True,
+ "api_key_required": True,
+ "base_url_required": False,
+ "accepts_api_key": True,
+ },
+ {
+ "id": "anthropic",
+ "description": "Anthropic API",
+ "adapter": "anthropic",
+ "base_url": "",
+ "default_model": "claude-3-5-sonnet",
+ "builtin": True,
+ "api_key_set": False,
+ "api_key_required": True,
+ "base_url_required": False,
+ "accepts_api_key": True,
+ },
+ {
+ "id": "legacy-local",
+ "description": "Legacy Local",
+ "adapter": "open_ai_completions",
+ "base_url": "http://localhost:11434/v1",
+ "default_model": "legacy-model",
+ "builtin": False,
+ "api_key_set": True,
+ "api_key_required": True,
+ "base_url_required": True,
+ "accepts_api_key": True,
+ },
+ ],
+ }
+
+
+async def test_reborn_legacy_settings_tools_search_and_clear(
+ reborn_v2_server, reborn_v2_browser
+):
+ harness = await _open_mocked_settings_page(
+ reborn_v2_server,
+ reborn_v2_browser,
+ tab="tools",
+ )
+ try:
+ page = harness["page"]
+ search = harness["search"]
+
+ await expect(page.get_by_text("echo", exact=True)).to_be_visible(timeout=5000)
+ await expect(page.get_by_text("search_web", exact=True)).to_be_visible(timeout=5000)
+
+ await search.fill("echo")
+ await expect(page.get_by_text("echo", exact=True)).to_be_visible()
+ await expect(page.get_by_text("search_web", exact=True)).to_have_count(0)
+ await expect(page.get_by_text("1 / 2")).to_be_visible()
+
+ await page.get_by_role("button", name="Clear search").click()
+ await expect(search).to_have_value("")
+ await expect(page.get_by_text("search_web", exact=True)).to_be_visible()
+
+ await search.fill("missing-tool")
+ await expect(page.get_by_text("No tools match the filter.")).to_be_visible()
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_settings_skills_search_empty_state(
+ reborn_v2_server, reborn_v2_browser
+):
+ harness = await _open_mocked_settings_page(
+ reborn_v2_server,
+ reborn_v2_browser,
+ tab="skills",
+ )
+ try:
+ page = harness["page"]
+ search = harness["search"]
+
+ await expect(page.get_by_text("markdown-helper", exact=True)).to_be_visible(
+ timeout=5000
+ )
+ await expect(page.get_by_text("workspace-helper", exact=True)).to_be_visible(
+ timeout=5000
+ )
+
+ await search.fill("workspace")
+ await expect(page.get_by_text("workspace-helper", exact=True)).to_be_visible()
+ await expect(page.get_by_text("markdown-helper", exact=True)).to_have_count(0)
+
+ await search.fill("no-such-skill")
+ await expect(page.get_by_text('No settings match "no-such-skill"')).to_be_visible()
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_settings_channels_search(
+ reborn_v2_server, reborn_v2_browser
+):
+ harness = await _open_mocked_settings_page(
+ reborn_v2_server,
+ reborn_v2_browser,
+ tab="channels",
+ )
+ try:
+ page = harness["page"]
+ search = harness["search"]
+
+ await expect(page.get_by_text("Telegram Channel", exact=True)).to_be_visible(
+ timeout=5000
+ )
+ await expect(page.get_by_text("Beta MCP", exact=True)).to_be_visible(timeout=5000)
+
+ await search.fill("telegram")
+ await expect(page.get_by_text("Telegram Channel", exact=True)).to_be_visible()
+ await expect(page.get_by_text("Beta MCP", exact=True)).to_have_count(0)
+
+ await search.fill("nothing-matches-this")
+ await expect(
+ page.get_by_text('No settings match "nothing-matches-this"')
+ ).to_be_visible()
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_settings_inference_add_test_and_activate_provider(
+ reborn_v2_server, reborn_v2_browser
+):
+ llm_state = _mock_llm_state()
+ llm_requests: list[dict] = []
+ harness = await _open_mocked_settings_page(
+ reborn_v2_server,
+ reborn_v2_browser,
+ tab="inference",
+ llm_state=llm_state,
+ llm_requests=llm_requests,
+ )
+ try:
+ page = harness["page"]
+
+ await expect(page.get_by_text("LLM provider", exact=True)).to_be_visible(
+ timeout=5000
+ )
+ await expect(
+ page.get_by_text("gpt-4.1-mini", exact=True).first
+ ).to_be_visible()
+ await expect(_provider_card(page, "openai")).to_be_visible(timeout=5000)
+ await expect(_provider_card(page, "legacy-local")).to_be_visible(timeout=5000)
+
+ await page.get_by_role("button", name="Add provider").click()
+ dialog = page.get_by_role("dialog")
+ await expect(dialog.get_by_role("heading", name="New provider")).to_be_visible()
+
+ await dialog.get_by_label("Display name").fill("Acme LLM")
+ await expect(dialog.get_by_label("Provider ID")).to_have_value("acme-llm")
+ await dialog.get_by_label("Base URL").fill("https://llm.acme.test/v1")
+ await dialog.get_by_label("API key").fill("acme-secret")
+ await dialog.get_by_label("Default model").fill("stale-model")
+
+ await dialog.get_by_role("button", name="Fetch models").click()
+ await expect(dialog.get_by_text("2 models found.")).to_be_visible(timeout=5000)
+ await dialog.get_by_role("combobox").nth(1).select_option("acme-pro")
+
+ await dialog.get_by_role("button", name="Test connection").click()
+ await expect(dialog.get_by_text("probe ok for acme-pro")).to_be_visible(
+ timeout=5000
+ )
+
+ await dialog.get_by_role("button", name="Save").click()
+ await expect(page.get_by_text('Added provider "Acme LLM".')).to_be_visible(
+ timeout=5000
+ )
+
+ acme_card = _provider_card(page, "acme-llm")
+ await expect(acme_card).to_be_visible(timeout=5000)
+ await acme_card.get_by_role("button", name="Use").click()
+ await expect(page.get_by_text("Switched to Acme LLM.")).to_be_visible(
+ timeout=5000
+ )
+ await expect(page.get_by_text("acme-llm", exact=True).first).to_be_visible()
+ await expect(page.get_by_text("acme-pro", exact=True).first).to_be_visible()
+
+ assert {
+ "kind": "list_models",
+ "payload": {
+ "adapter": "open_ai_completions",
+ "base_url": "https://llm.acme.test/v1",
+ "provider_id": "acme-llm",
+ "provider_type": "custom",
+ "model": "stale-model",
+ "api_key": "acme-secret",
+ },
+ } in llm_requests
+ assert {
+ "kind": "test_connection",
+ "payload": {
+ "adapter": "open_ai_completions",
+ "base_url": "https://llm.acme.test/v1",
+ "provider_id": "acme-llm",
+ "provider_type": "custom",
+ "model": "acme-pro",
+ "api_key": "acme-secret",
+ },
+ } in llm_requests
+ assert any(
+ request["kind"] == "upsert"
+ and request["payload"]["id"] == "acme-llm"
+ and request["payload"]["default_model"] == "acme-pro"
+ and request["payload"]["api_key"] == "acme-secret"
+ for request in llm_requests
+ )
+ assert {
+ "kind": "active",
+ "payload": {"provider_id": "acme-llm", "model": "acme-pro"},
+ } in llm_requests
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_settings_inference_edit_and_delete_custom_provider(
+ reborn_v2_server, reborn_v2_browser
+):
+ llm_state = _mock_llm_state()
+ llm_requests: list[dict] = []
+ harness = await _open_mocked_settings_page(
+ reborn_v2_server,
+ reborn_v2_browser,
+ tab="inference",
+ llm_state=llm_state,
+ llm_requests=llm_requests,
+ )
+ try:
+ page = harness["page"]
+ legacy_card = _provider_card(page, "legacy-local")
+ await expect(legacy_card).to_be_visible(timeout=5000)
+
+ await legacy_card.get_by_test_id(SEL_V2["llm_provider_disclosure"]).click()
+ await legacy_card.get_by_role("button", name="Edit").click()
+ dialog = page.get_by_role("dialog")
+ await expect(
+ dialog.get_by_role("heading", name="Edit provider")
+ ).to_be_visible()
+
+ await dialog.get_by_label("Base URL").fill("http://127.0.0.1:11435/v1")
+ await dialog.get_by_label("Default model").fill("legacy-v2")
+ await dialog.get_by_role("button", name="Save").click()
+ await expect(
+ page.get_by_text('Updated provider "Legacy Local".')
+ ).to_be_visible(timeout=5000)
+ await expect(legacy_card.get_by_text("legacy-v2", exact=True)).to_be_visible(
+ timeout=5000
+ )
+
+ edit_request = next(
+ request
+ for request in llm_requests
+ if request["kind"] == "upsert"
+ and request["payload"].get("id") == "legacy-local"
+ )
+ assert edit_request["payload"]["base_url"] == "http://127.0.0.1:11435/v1"
+ assert edit_request["payload"]["default_model"] == "legacy-v2"
+ assert "api_key" not in edit_request["payload"]
+
+ page.once("dialog", lambda browser_dialog: browser_dialog.accept())
+ await legacy_card.get_by_role("button", name="Delete").click()
+ await expect(page.get_by_text("Provider deleted.")).to_be_visible(timeout=5000)
+ await expect(_provider_card(page, "legacy-local")).to_have_count(0)
+ assert {
+ "kind": "delete",
+ "payload": {"provider_id": "legacy-local"},
+ } in llm_requests
+ finally:
+ await harness["context"].close()
diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_skills.py b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_skills.py
new file mode 100644
index 00000000000..16c3797f3d7
--- /dev/null
+++ b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_skills.py
@@ -0,0 +1,257 @@
+"""Legacy Skills settings lifecycle coverage ported to Reborn WebChat v2."""
+
+import asyncio
+import json
+from urllib.parse import unquote, urlparse
+
+from playwright.async_api import expect
+
+from helpers import REBORN_V2_AUTH_TOKEN, SEL_V2
+from reborn_webui_harness import (
+ reborn_v2_browser, # noqa: F401 - imported fixture
+ reborn_v2_server, # noqa: F401 - imported fixture
+)
+
+
+MOCK_INSTALLED_SKILL = {
+ "name": "markdown-helper",
+ "description": "Deterministic E2E skill for markdown workflows.",
+ "version": "1.0.0",
+ "trust": "Installed",
+ "source_kind": "installed",
+ "keywords": ["markdown", "e2e"],
+ "usage_hint": "Type `/markdown-helper` in chat to force-activate this skill.",
+ "has_requirements": False,
+ "has_scripts": False,
+ "can_edit": True,
+ "can_delete": True,
+ "auto_activate": True,
+}
+
+MOCK_SYSTEM_SKILL = {
+ "name": "system-helper",
+ "description": "Read-only system helper.",
+ "version": "1.0.0",
+ "trust": "Trusted",
+ "source_kind": "system",
+ "keywords": ["system"],
+ "has_requirements": False,
+ "has_scripts": False,
+ "can_edit": False,
+ "can_delete": False,
+}
+
+MOCK_WORKSPACE_SKILL = {
+ "name": "workspace-helper",
+ "description": "Read-only workspace helper.",
+ "version": "1.0.0",
+ "trust": "Trusted",
+ "source_kind": "workspace",
+ "keywords": ["workspace"],
+ "has_requirements": False,
+ "has_scripts": False,
+ "can_edit": False,
+ "can_delete": False,
+}
+
+MOCK_SKILL_CONTENT = (
+ "---\n"
+ "name: markdown-helper\n"
+ "description: Deterministic E2E skill for markdown workflows.\n"
+ "---\n\n"
+ "# Markdown Helper\n"
+)
+
+
+async def _open_mocked_skills_page(reborn_v2_server, reborn_v2_browser, *, initial_skills=None):
+ context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720})
+ page = await context.new_page()
+ installed = [dict(skill) for skill in (initial_skills or [])]
+ install_requests: list[dict] = []
+ update_requests: list[dict] = []
+ delete_requests: list[str] = []
+
+ async def fulfill_json(route, payload, status=200):
+ await route.fulfill(
+ status=status,
+ content_type="application/json",
+ body=json.dumps(payload),
+ headers={"Cache-Control": "no-store"},
+ )
+
+ async def handle_skills(route):
+ nonlocal installed
+ request = route.request
+ path = urlparse(request.url).path
+
+ if path == "/api/webchat/v2/skills" and request.method == "GET":
+ await fulfill_json(
+ route,
+ {
+ "skills": installed,
+ "count": len(installed),
+ "auto_activate_learned": True,
+ },
+ )
+ return
+
+ if path == "/api/webchat/v2/skills/install" and request.method == "POST":
+ payload = json.loads(request.post_data or "{}")
+ install_requests.append({"headers": request.headers, "body": payload})
+ if not any(skill["name"] == payload.get("name") for skill in installed):
+ skill = dict(MOCK_INSTALLED_SKILL)
+ skill["name"] = payload.get("name", skill["name"])
+ installed = [skill]
+ await fulfill_json(
+ route,
+ {"success": True, "message": f"Skill '{payload.get('name')}' installed"},
+ )
+ return
+
+ if path.startswith("/api/webchat/v2/skills/") and request.method == "GET":
+ name = unquote(path.removeprefix("/api/webchat/v2/skills/"))
+ await fulfill_json(route, {"name": name, "content": MOCK_SKILL_CONTENT})
+ return
+
+ if path.startswith("/api/webchat/v2/skills/") and request.method == "PUT":
+ name = unquote(path.removeprefix("/api/webchat/v2/skills/"))
+ update_requests.append(
+ {
+ "name": name,
+ "headers": request.headers,
+ "body": json.loads(request.post_data or "{}"),
+ }
+ )
+ await fulfill_json(route, {"success": True, "message": f"Skill '{name}' updated"})
+ return
+
+ if path.startswith("/api/webchat/v2/skills/") and request.method == "DELETE":
+ name = unquote(path.removeprefix("/api/webchat/v2/skills/"))
+ delete_requests.append(name)
+ installed = [skill for skill in installed if skill["name"] != name]
+ await fulfill_json(route, {"success": True, "message": f"Skill '{name}' removed"})
+ return
+
+ await route.continue_()
+
+ await page.route("**/api/webchat/v2/skills**", handle_skills)
+ await page.goto(f"{reborn_v2_server}/v2/settings/skills?token={REBORN_V2_AUTH_TOKEN}")
+ await expect(page.get_by_text("Add skill")).to_be_visible(timeout=15000)
+
+ return {
+ "context": context,
+ "page": page,
+ "install_requests": install_requests,
+ "update_requests": update_requests,
+ "delete_requests": delete_requests,
+ }
+
+
+async def _add_mock_skill(page):
+ await page.get_by_placeholder(SEL_V2["skill_name_placeholder"]).fill(
+ "markdown-helper"
+ )
+ await page.get_by_placeholder(SEL_V2["skill_content_placeholder"]).fill(
+ MOCK_SKILL_CONTENT
+ )
+ await page.get_by_role("button", name="Add").click()
+ card = page.locator(SEL_V2["skills_card"]).filter(has_text="markdown-helper")
+ await expect(card).to_be_visible(timeout=5000)
+ return card
+
+
+async def test_reborn_legacy_skills_tab_visible(reborn_v2_server, reborn_v2_browser):
+ harness = await _open_mocked_skills_page(reborn_v2_server, reborn_v2_browser)
+ try:
+ page = harness["page"]
+ await expect(page.get_by_text("Add skill")).to_be_visible()
+ await expect(
+ page.get_by_placeholder(SEL_V2["skill_name_placeholder"])
+ ).to_be_visible()
+ await expect(
+ page.get_by_placeholder(SEL_V2["skill_content_placeholder"])
+ ).to_be_visible()
+ await expect(page.get_by_role("button", name="Default: On")).to_be_visible()
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_skills_add_edit_delete(reborn_v2_server, reborn_v2_browser):
+ harness = await _open_mocked_skills_page(reborn_v2_server, reborn_v2_browser)
+ try:
+ page = harness["page"]
+ card = await _add_mock_skill(page)
+
+ assert len(harness["install_requests"]) == 1
+ assert harness["install_requests"][0]["headers"].get("x-confirm-action") == "true"
+ assert harness["install_requests"][0]["body"] == {
+ "name": "markdown-helper",
+ "content": MOCK_SKILL_CONTENT.strip(),
+ }
+
+ await card.get_by_role("button", name="Edit").click()
+ editor = card.locator("textarea")
+ await expect(editor).to_be_visible(timeout=5000)
+ await editor.fill(
+ "---\nname: markdown-helper\ndescription: Updated E2E skill\n---\n\n# Updated\n"
+ )
+ await card.get_by_role("button", name="Save").click()
+ await expect(editor).to_be_hidden(timeout=5000)
+
+ assert len(harness["update_requests"]) == 1
+ update = harness["update_requests"][0]
+ assert update["name"] == "markdown-helper"
+ assert update["headers"].get("x-confirm-action") == "true"
+ assert "Updated E2E skill" in update["body"]["content"]
+
+ loop = asyncio.get_running_loop()
+ dialog_future = loop.create_future()
+
+ async def handle_dialog(dialog):
+ if not dialog_future.done():
+ dialog_future.set_result(
+ {"type": dialog.type, "message": dialog.message}
+ )
+ await dialog.accept()
+
+ page.once("dialog", handle_dialog)
+ await card.get_by_role("button", name="Delete").click()
+ dialog = await asyncio.wait_for(dialog_future, timeout=5)
+ assert dialog["type"] == "confirm"
+ assert "markdown-helper" in dialog["message"]
+
+ await expect(
+ page.locator(SEL_V2["skills_card"]).filter(has_text="markdown-helper")
+ ).to_have_count(0, timeout=5000)
+ assert harness["delete_requests"] == ["markdown-helper"]
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_skills_read_only_sources_hide_edit_and_delete(
+ reborn_v2_server, reborn_v2_browser
+):
+ harness = await _open_mocked_skills_page(
+ reborn_v2_server,
+ reborn_v2_browser,
+ initial_skills=[MOCK_SYSTEM_SKILL, MOCK_WORKSPACE_SKILL],
+ )
+ try:
+ page = harness["page"]
+ system_card = page.locator(SEL_V2["skills_card"]).filter(
+ has_text="system-helper"
+ )
+ workspace_card = page.locator(SEL_V2["skills_card"]).filter(
+ has_text="workspace-helper"
+ )
+ await expect(system_card).to_be_visible(timeout=5000)
+ await expect(workspace_card).to_be_visible(timeout=5000)
+
+ for card in (system_card, workspace_card):
+ await expect(card.get_by_role("button", name="Edit")).to_have_count(0)
+ await expect(card.get_by_role("button", name="Delete")).to_have_count(0)
+ await expect(
+ card.get_by_role("button", name="Auto-activate: On")
+ ).to_have_count(0)
+ finally:
+ await harness["context"].close()
diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py
new file mode 100644
index 00000000000..b2112e8d7da
--- /dev/null
+++ b/tests/e2e/scenarios/test_reborn_webui_v2_legacy_tool_permissions.py
@@ -0,0 +1,467 @@
+"""Legacy tool-permission coverage ported to Reborn WebChat v2."""
+
+import asyncio
+import json
+from pathlib import Path
+from urllib.parse import quote, unquote, urlparse
+
+import httpx
+import pytest
+from playwright.async_api import expect
+
+from helpers import REBORN_V2_AUTH_TOKEN, SEL_V2
+from reborn_webui_harness import (
+ client_action_id,
+ create_thread,
+ reborn_v2_browser, # noqa: F401 - imported fixture
+ reborn_v2_restartable_server, # noqa: F401 - imported fixture
+ reborn_v2_server, # noqa: F401 - imported fixture
+ reborn_bearer_headers,
+ send_message,
+ wait_for_assistant_message,
+)
+
+
+def _tool_entry(
+ name: str,
+ *,
+ state: str,
+ default_state: str = "ask_each_time",
+ source: str = "override",
+ mutable: bool = True,
+ description: str | None = None,
+) -> dict:
+ return {
+ "key": f"tool.{name}",
+ "value": {
+ "name": name,
+ "description": description or f"{name} deterministic test tool.",
+ "state": state,
+ "default_state": default_state,
+ "locked": not mutable,
+ "effective_source": source,
+ },
+ "mutable": mutable,
+ "source": source,
+ }
+
+
+async def _open_mocked_tools_page(reborn_v2_server, reborn_v2_browser):
+ context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720})
+ page = await context.new_page()
+ auto_approve = {"enabled": False}
+ tool_states = {
+ "echo": {
+ "state": "always_allow",
+ "default_state": "ask_each_time",
+ "source": "override",
+ "mutable": True,
+ "description": "Echo text back.",
+ },
+ "tool.financial": {
+ "state": "ask_each_time",
+ "default_state": "ask_each_time",
+ "source": "locked",
+ "mutable": False,
+ "description": "Hard-floor approval tool.",
+ },
+ }
+ auto_approve_requests: list[dict] = []
+ permission_requests: list[dict] = []
+
+ def entries():
+ return [
+ {
+ "key": "agent.auto_approve_tools",
+ "value": auto_approve["enabled"],
+ "mutable": True,
+ "source": "override" if auto_approve["enabled"] else "default",
+ },
+ *[
+ _tool_entry(
+ name,
+ state=data["state"],
+ default_state=data["default_state"],
+ source=data["source"],
+ mutable=data["mutable"],
+ description=data["description"],
+ )
+ for name, data in tool_states.items()
+ ],
+ ]
+
+ async def fulfill_json(route, payload, status=200):
+ await route.fulfill(
+ status=status,
+ content_type="application/json",
+ body=json.dumps(payload),
+ headers={"Cache-Control": "no-store"},
+ )
+
+ async def handle_settings_tools(route):
+ request = route.request
+ path = urlparse(request.url).path
+
+ if path == "/api/webchat/v2/settings/tools" and request.method == "GET":
+ await fulfill_json(route, {"entries": entries()})
+ return
+
+ if path == "/api/webchat/v2/settings/tools" and request.method == "POST":
+ body = json.loads(request.post_data or "{}")
+ auto_approve_requests.append(body)
+ auto_approve["enabled"] = bool(body.get("enabled"))
+ await fulfill_json(
+ route,
+ {
+ "entry": {
+ "key": "agent.auto_approve_tools",
+ "value": auto_approve["enabled"],
+ "mutable": True,
+ "source": "override",
+ }
+ },
+ )
+ return
+
+ if (
+ path.startswith("/api/webchat/v2/settings/tools/")
+ and request.method == "POST"
+ ):
+ name = unquote(path.removeprefix("/api/webchat/v2/settings/tools/"))
+ body = json.loads(request.post_data or "{}")
+ permission_requests.append({"name": name, "body": body})
+ tool = tool_states[name]
+ if not tool["mutable"]:
+ await fulfill_json(
+ route,
+ {"kind": "bad_request", "message": "locked tool"},
+ status=400,
+ )
+ return
+
+ requested = body.get("state") or "default"
+ if requested == "default":
+ tool["state"] = tool["default_state"]
+ tool["source"] = "default"
+ else:
+ tool["state"] = requested
+ tool["source"] = "override"
+
+ await fulfill_json(
+ route,
+ {
+ "entry": _tool_entry(
+ name,
+ state=tool["state"],
+ default_state=tool["default_state"],
+ source=tool["source"],
+ mutable=tool["mutable"],
+ description=tool["description"],
+ )
+ },
+ )
+ return
+
+ await route.continue_()
+
+ await page.route("**/api/webchat/v2/settings/tools**", handle_settings_tools)
+ await page.goto(f"{reborn_v2_server}/v2/settings/tools?token={REBORN_V2_AUTH_TOKEN}")
+ await expect(
+ page.get_by_placeholder(SEL_V2["settings_search_placeholder"])
+ ).to_be_visible(timeout=15000)
+ await expect(page.get_by_text("Tool permissions")).to_be_visible(timeout=5000)
+
+ return {
+ "context": context,
+ "page": page,
+ "auto_approve_requests": auto_approve_requests,
+ "permission_requests": permission_requests,
+ }
+
+
+def _tool_row(page, name: str):
+ return page.locator(SEL_V2["settings_tool_row_for"].format(name=name))
+
+
+@pytest.fixture
+def reborn_approval_artifact_cleanup():
+ yield
+ for label in ("first", "second"):
+ Path(f"reborn-approval-{label}.txt").unlink(missing_ok=True)
+
+
+async def _set_real_auto_approve(reborn_v2_server: str, enabled: bool):
+ headers = {"Authorization": f"Bearer {REBORN_V2_AUTH_TOKEN}"}
+ async with httpx.AsyncClient(headers=headers) as client:
+ response = await client.post(
+ f"{reborn_v2_server}/api/webchat/v2/settings/tools",
+ json={"enabled": enabled},
+ timeout=15,
+ )
+ response.raise_for_status()
+ return response.json()
+
+
+async def _get_real_tool_state(
+ client: httpx.AsyncClient, base_url: str, capability_id: str
+) -> dict:
+ response = await client.get(
+ f"{base_url}/api/webchat/v2/settings/tools",
+ timeout=15,
+ )
+ response.raise_for_status()
+ for entry in response.json().get("entries", []):
+ if entry.get("key") == f"tool.{capability_id}":
+ return entry
+ raise AssertionError(f"{capability_id} missing from Tools settings")
+
+
+async def _wait_for_gate_prompt_after_send(
+ base_url: str, thread_id: str, content: str
+) -> dict:
+ url = (
+ f"{base_url}/api/webchat/v2/threads/{thread_id}/events"
+ f"?token={REBORN_V2_AUTH_TOKEN}"
+ )
+ timeout = httpx.Timeout(60.0, read=60.0)
+ async with httpx.AsyncClient(timeout=timeout) as stream_client:
+ async with stream_client.stream("GET", url) as response:
+ response.raise_for_status()
+ async with httpx.AsyncClient(headers=reborn_bearer_headers()) as action_client:
+ await send_message(action_client, base_url, thread_id, content)
+
+ event_name = None
+ data_lines: list[str] = []
+ async with asyncio.timeout(45):
+ async for line in response.aiter_lines():
+ if line.startswith(":"):
+ continue
+ if line.startswith("event:"):
+ event_name = line.removeprefix("event:").strip()
+ continue
+ if line.startswith("data:"):
+ data_lines.append(line.removeprefix("data:").lstrip())
+ continue
+ if line == "":
+ if event_name == "gate" and data_lines:
+ frame = json.loads("\n".join(data_lines))
+ return frame["prompt"]
+ event_name = None
+ data_lines = []
+ raise AssertionError("SSE stream closed before a gate prompt arrived")
+
+
+async def test_reborn_legacy_tool_permissions_tab_visible(
+ reborn_v2_server, reborn_v2_browser
+):
+ harness = await _open_mocked_tools_page(reborn_v2_server, reborn_v2_browser)
+ try:
+ page = harness["page"]
+ await expect(page.get_by_text("Always allow eligible tools")).to_be_visible()
+ await expect(_tool_row(page, "echo")).to_be_visible(timeout=5000)
+ await expect(_tool_row(page, "tool.financial")).to_be_visible(timeout=5000)
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_tool_permission_select_persists_after_reload(
+ reborn_v2_server, reborn_v2_browser
+):
+ harness = await _open_mocked_tools_page(reborn_v2_server, reborn_v2_browser)
+ try:
+ page = harness["page"]
+ select = page.get_by_label("Permission for echo")
+ await expect(select).to_have_value("always_allow", timeout=5000)
+
+ await select.select_option("ask_each_time")
+ await expect(select).to_have_value("ask_each_time")
+ await expect(_tool_row(page, "echo").get_by_text("saved")).to_be_visible(timeout=5000)
+ assert harness["permission_requests"][-1] == {
+ "name": "echo",
+ "body": {"state": "ask_each_time"},
+ }
+
+ await page.reload()
+ await expect(
+ page.get_by_placeholder(SEL_V2["settings_search_placeholder"])
+ ).to_be_visible(timeout=15000)
+ await expect(page.get_by_label("Permission for echo")).to_have_value(
+ "ask_each_time",
+ timeout=5000,
+ )
+
+ await page.get_by_label("Permission for echo").select_option("default")
+ await expect(page.get_by_label("Permission for echo")).to_have_value("default")
+ assert harness["permission_requests"][-1] == {
+ "name": "echo",
+ "body": {"state": "default"},
+ }
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_locked_tool_shows_badge_without_select(
+ reborn_v2_server, reborn_v2_browser
+):
+ harness = await _open_mocked_tools_page(reborn_v2_server, reborn_v2_browser)
+ try:
+ page = harness["page"]
+ locked = _tool_row(page, "tool.financial")
+ await expect(locked).to_be_visible(timeout=5000)
+ await expect(locked.locator(SEL_V2["settings_tool_lock"])).to_be_visible()
+ await expect(
+ locked.get_by_label("Permission for tool.financial")
+ ).to_have_count(0)
+ await expect(locked.get_by_text("Ask each time")).to_be_visible()
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_auto_approve_switch_persists(
+ reborn_v2_server, reborn_v2_browser
+):
+ harness = await _open_mocked_tools_page(reborn_v2_server, reborn_v2_browser)
+ try:
+ page = harness["page"]
+ switch = page.get_by_role("switch", name="Always allow eligible tools")
+ await expect(switch).to_have_attribute("aria-checked", "false")
+ await switch.click()
+ await expect(switch).to_have_attribute("aria-checked", "true")
+ assert harness["auto_approve_requests"] == [{"enabled": True}]
+ finally:
+ await harness["context"].close()
+
+
+async def test_reborn_legacy_auto_approve_real_api_persists_across_browser_contexts(
+ reborn_v2_server,
+ reborn_v2_browser,
+):
+ await _set_real_auto_approve(reborn_v2_server, False)
+ context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720})
+ page = await context.new_page()
+
+ try:
+ update = await _set_real_auto_approve(reborn_v2_server, True)
+ assert update["entry"]["key"] == "agent.auto_approve_tools"
+ assert update["entry"]["value"] is True
+
+ await page.goto(
+ f"{reborn_v2_server}/v2/settings/tools?token={REBORN_V2_AUTH_TOKEN}"
+ )
+ await expect(
+ page.get_by_placeholder(SEL_V2["settings_search_placeholder"])
+ ).to_be_visible(timeout=15000)
+ switch = page.get_by_role("switch", name="Always allow eligible tools")
+ await expect(switch).to_have_attribute("aria-checked", "true", timeout=5000)
+ finally:
+ await context.close()
+ await _set_real_auto_approve(reborn_v2_server, False)
+
+
+async def test_reborn_legacy_tool_permission_real_api_persists_and_rejects_locked(
+ reborn_v2_server,
+):
+ headers = {"Authorization": f"Bearer {REBORN_V2_AUTH_TOKEN}"}
+ async with httpx.AsyncClient(headers=headers) as client:
+ response = await client.get(
+ f"{reborn_v2_server}/api/webchat/v2/settings/tools",
+ timeout=15,
+ )
+ response.raise_for_status()
+ entries = response.json().get("entries", [])
+
+ tools = [entry for entry in entries if entry.get("key", "").startswith("tool.")]
+ mutable = next((entry for entry in tools if entry.get("mutable") is not False), None)
+ locked = next((entry for entry in tools if entry.get("mutable") is False), None)
+
+ if mutable is None:
+ pytest.skip("Reborn test catalog has no mutable operator tool")
+
+ capability_id = mutable["key"].removeprefix("tool.")
+ update = await client.post(
+ f"{reborn_v2_server}/api/webchat/v2/settings/tools/{capability_id}",
+ json={"state": "disabled"},
+ timeout=15,
+ )
+ update.raise_for_status()
+ assert update.json()["entry"]["value"]["state"] == "disabled"
+ assert update.json()["entry"]["mutable"] is True
+
+ reset = await client.post(
+ f"{reborn_v2_server}/api/webchat/v2/settings/tools/{capability_id}",
+ json={"state": "default"},
+ timeout=15,
+ )
+ reset.raise_for_status()
+ assert reset.json()["entry"]["key"] == mutable["key"]
+
+ if locked is not None:
+ locked_id = locked["key"].removeprefix("tool.")
+ rejected = await client.post(
+ f"{reborn_v2_server}/api/webchat/v2/settings/tools/{locked_id}",
+ json={"state": "always_allow"},
+ timeout=15,
+ )
+ assert rejected.status_code >= 400
+
+
+async def test_reborn_legacy_always_approve_survives_reborn_restart(
+ reborn_v2_restartable_server,
+ reborn_approval_artifact_cleanup,
+):
+ state, start_server, stop_server = reborn_v2_restartable_server
+ capability_id = "builtin.write_file"
+
+ async with httpx.AsyncClient(headers=reborn_bearer_headers()) as client:
+ base_url = state["base_url"]
+ reset = await client.post(
+ f"{base_url}/api/webchat/v2/settings/tools/{capability_id}",
+ json={"state": "default"},
+ timeout=15,
+ )
+ reset.raise_for_status()
+ thread_id = await create_thread(client, base_url)
+
+ first_prompt = await _wait_for_gate_prompt_after_send(
+ state["base_url"],
+ thread_id,
+ "reborn write approval file first",
+ )
+ assert first_prompt["allow_always"] is True
+ assert first_prompt["approval_context"]["tool_name"] == capability_id
+
+ async with httpx.AsyncClient(headers=reborn_bearer_headers()) as client:
+ gate_ref = quote(first_prompt["gate_ref"], safe="")
+ resolve = await client.post(
+ (
+ f"{state['base_url']}/api/webchat/v2/threads/{thread_id}/runs/"
+ f"{first_prompt['turn_run_id']}/gates/{gate_ref}/resolve"
+ ),
+ json={
+ "client_action_id": client_action_id(),
+ "resolution": "approved",
+ "always": True,
+ },
+ timeout=15,
+ )
+ resolve.raise_for_status()
+ await wait_for_assistant_message(client, state["base_url"], thread_id)
+
+ persisted = await _get_real_tool_state(client, state["base_url"], capability_id)
+ assert persisted["value"]["state"] == "always_allow"
+
+ await stop_server()
+ restarted_url = await start_server()
+
+ async with httpx.AsyncClient(headers=reborn_bearer_headers()) as client:
+ restarted = await _get_real_tool_state(client, restarted_url, capability_id)
+ assert restarted["value"]["state"] == "always_allow"
+
+ second_thread_id = await create_thread(client, restarted_url)
+ await send_message(
+ client,
+ restarted_url,
+ second_thread_id,
+ "reborn write approval file second",
+ )
+ await wait_for_assistant_message(client, restarted_url, second_thread_id)