From 11bd5c6fd776b796c6c8e29dba4f6dfc8f338b0b Mon Sep 17 00:00:00 2001
From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com>
Date: Sat, 11 Jul 2026 12:37:07 +0800
Subject: [PATCH] feat(wizard): add scenario templates and EBA release gate
---
skills/scripts/e2e/onebot-runtime-probe.py | 93 +++++
.../e2e/wizard-onebot-agent-runtime.mjs | 348 ++++++++++++++++++
skills/skills.index.json | 46 +++
.../cases/wizard-onebot-agent-runtime.yaml | 61 +++
.../suites/eba-release-gate.yaml | 14 +
web/src/app/wizard/page.tsx | 38 +-
web/src/i18n/locales/en-US.ts | 9 +-
web/src/i18n/locales/ja-JP.ts | 9 +-
web/src/i18n/locales/zh-Hans.ts | 8 +-
9 files changed, 603 insertions(+), 23 deletions(-)
create mode 100644 skills/scripts/e2e/onebot-runtime-probe.py
create mode 100644 skills/scripts/e2e/wizard-onebot-agent-runtime.mjs
create mode 100644 skills/skills/langbot-testing/cases/wizard-onebot-agent-runtime.yaml
create mode 100644 skills/skills/langbot-testing/suites/eba-release-gate.yaml
diff --git a/skills/scripts/e2e/onebot-runtime-probe.py b/skills/scripts/e2e/onebot-runtime-probe.py
new file mode 100644
index 000000000..a080571fa
--- /dev/null
+++ b/skills/scripts/e2e/onebot-runtime-probe.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""Drive one deterministic event through an enabled OneBot reverse WebSocket."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import time
+
+import websockets
+
+
+async def run(port: int) -> None:
+ uri = f'ws://127.0.0.1:{port}/ws'
+ headers = {
+ 'X-Self-ID': '900001',
+ 'X-Client-Role': 'Universal',
+ 'User-Agent': 'LangBot-E2E-OneBot/1.0',
+ }
+ connect_deadline = time.monotonic() + 15
+ connection = None
+ while time.monotonic() < connect_deadline:
+ try:
+ connection = await websockets.connect(uri, additional_headers=headers)
+ break
+ except OSError:
+ await asyncio.sleep(0.25)
+ if connection is None:
+ raise RuntimeError(f'OneBot reverse WebSocket did not open on port {port}')
+
+ actions: list[str] = []
+ event_deadline = time.monotonic() + 20
+ async with connection as ws:
+ await ws.send(
+ json.dumps(
+ {
+ 'post_type': 'notice',
+ 'notice_type': 'group_increase',
+ 'sub_type': 'invite',
+ 'time': int(time.time()),
+ 'self_id': 900001,
+ 'group_id': 20001,
+ 'operator_id': 10002,
+ 'user_id': 10003,
+ }
+ )
+ )
+
+ delivered = False
+ while time.monotonic() < event_deadline and not delivered:
+ try:
+ raw = await asyncio.wait_for(ws.recv(), timeout=2)
+ except asyncio.TimeoutError:
+ continue
+ request = json.loads(raw)
+ action = request.get('action', '')
+ actions.append(action)
+ if action == 'get_group_info':
+ data = {'group_id': 20001, 'group_name': 'LangBot Runtime QA'}
+ elif action == 'get_group_member_info':
+ data = {
+ 'group_id': 20001,
+ 'user_id': 10003,
+ 'nickname': 'Runtime QA Member',
+ 'card': 'Runtime QA Member',
+ }
+ elif action == 'send_group_msg':
+ data = {'message_id': 70001}
+ delivered = True
+ else:
+ data = {}
+ await ws.send(
+ json.dumps(
+ {
+ 'status': 'ok',
+ 'retcode': 0,
+ 'data': data,
+ 'echo': request.get('echo'),
+ }
+ )
+ )
+
+ if not delivered:
+ raise RuntimeError(f'Agent output was not delivered; actions={actions}')
+ print(json.dumps({'connected': True, 'actions': actions, 'delivered': True}))
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--port', type=int, required=True)
+ args = parser.parse_args()
+ asyncio.run(run(args.port))
diff --git a/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs b/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs
new file mode 100644
index 000000000..b270634e3
--- /dev/null
+++ b/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs
@@ -0,0 +1,348 @@
+#!/usr/bin/env node
+
+import { spawn } from "node:child_process";
+import { createServer } from "node:net";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ createBrowser,
+ ensureAuthenticatedBrowser,
+ ensureEvidence,
+ evidencePaths,
+ exitCode,
+ loadEnvFiles,
+ localIsoWithOffset,
+ pathExists,
+ resolveLangBotRepo,
+ safeScreenshot,
+ scanBrowserDiagnostics,
+ writeResult,
+} from "./lib/langbot-e2e.mjs";
+
+const caseId = "wizard-onebot-agent-runtime";
+await loadEnvFiles();
+const paths = evidencePaths(caseId);
+await ensureEvidence(paths);
+
+const startedAt = new Date();
+const frontendUrl = process.env.LANGBOT_FRONTEND_URL || "";
+const backendUrl = process.env.LANGBOT_BACKEND_URL || "";
+let browser;
+let botId = "";
+let agentId = "";
+let token = "";
+
+const result = {
+ source: "automation",
+ case_id: caseId,
+ run_id: paths.runId,
+ started_at: startedAt.toISOString(),
+ started_at_local: localIsoWithOffset(startedAt),
+ finished_at: "",
+ finished_at_local: "",
+ status: "fail",
+ reason: "",
+ url: "",
+ bot_id: "",
+ agent_id: "",
+ visible_signals: [],
+ api: {},
+ runtime: {},
+ cleanup: {},
+ diagnostics: null,
+ evidence: {
+ screenshot: paths.screenshot,
+ console_log: paths.consoleLog,
+ network_log: paths.networkLog,
+ automation_result_json: paths.automationResultJson,
+ result_json: paths.resultJson,
+ },
+ evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"],
+};
+
+async function getFreePort() {
+ const server = createServer();
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", resolve);
+ });
+ const address = server.address();
+ const port = typeof address === "object" && address ? address.port : 0;
+ await new Promise((resolve) => server.close(resolve));
+ if (!port) throw new Error("Could not allocate a temporary OneBot port.");
+ return port;
+}
+
+async function api(page, path, options = {}) {
+ return await page.evaluate(
+ async ({ baseUrl, path, options, token }) => {
+ const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, {
+ method: options.method || "GET",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body:
+ options.body === undefined ? undefined : JSON.stringify(options.body),
+ });
+ return {
+ status: response.status,
+ json: await response.json().catch(() => ({})),
+ };
+ },
+ { baseUrl: backendUrl, path, options, token },
+ );
+}
+
+async function runProbe(port) {
+ const repo = await resolveLangBotRepo();
+ const python = join(repo, ".venv", "bin", "python");
+ const script = join(
+ dirname(fileURLToPath(import.meta.url)),
+ "onebot-runtime-probe.py",
+ );
+ if (!(await pathExists(python))) {
+ throw new Error(`LangBot virtualenv Python is missing: ${python}`);
+ }
+ return await new Promise((resolve, reject) => {
+ const child = spawn(python, [script, "--port", String(port)], {
+ cwd: repo,
+ });
+ let stdout = "";
+ let stderr = "";
+ child.stdout.on("data", (chunk) => (stdout += chunk));
+ child.stderr.on("data", (chunk) => (stderr += chunk));
+ child.on("error", reject);
+ child.on("close", (code) => {
+ if (code === 0) {
+ resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
+ } else {
+ reject(
+ new Error(
+ stderr.trim() || stdout.trim() || `OneBot probe exited ${code}`,
+ ),
+ );
+ }
+ });
+ });
+}
+
+try {
+ if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured.");
+ if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured.");
+ const port = await getFreePort();
+
+ browser = await createBrowser(paths);
+ const { page } = browser;
+ await page.goto(frontendUrl, { waitUntil: "domcontentloaded" });
+ const auth = await ensureAuthenticatedBrowser(page, {
+ frontendUrl,
+ backendUrl,
+ });
+ if (auth.status !== "pass") {
+ result.status = auth.status;
+ throw new Error(auth.reason);
+ }
+ token = await page.evaluate(() => localStorage.getItem("token") || "");
+ if (!token) {
+ result.status = "blocked";
+ throw new Error("Authenticated browser token is unavailable.");
+ }
+
+ // Keep this case isolated from the instance's real onboarding state.
+ await page.route("**/api/v1/system/wizard/progress", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ code: 0, msg: "ok", data: {} }),
+ });
+ });
+
+ await page.goto(`${frontendUrl.replace(/\/$/, "")}/wizard`, {
+ waitUntil: "domcontentloaded",
+ });
+ await page
+ .getByRole("button", {
+ name: /Welcome new members|欢迎新成员|新しいメンバーを歓迎/,
+ })
+ .click();
+ await page.getByText("OneBot v11", { exact: true }).last().click();
+
+ const botResponse = page.waitForResponse(
+ (response) =>
+ response.request().method() === "POST" &&
+ /\/api\/v1\/platform\/bots$/.test(new URL(response.url()).pathname),
+ );
+ await page
+ .getByRole("button", {
+ name: /Confirm, Create Bot|确定,创建机器人|確定、ボットを作成/,
+ })
+ .click();
+ const botPayload = await (await botResponse).json();
+ botId = botPayload.data?.uuid || "";
+ if (!botId) throw new Error("Wizard did not create a Bot.");
+ result.bot_id = botId;
+ result.api.create_bot = { code: botPayload.code ?? null };
+
+ await page
+ .getByText(/Configure Your Bot|配置机器人|ボットを設定/)
+ .first()
+ .waitFor({ timeout: 15_000 });
+ const portInput = page.getByRole("spinbutton").first();
+ await portInput.fill(String(port));
+ await portInput.press("Tab");
+ await page
+ .getByRole("button", {
+ name: /Save & Enable Bot|保存并启用|保存して有効化/,
+ })
+ .click();
+ await page
+ .getByText(
+ /Bot configuration saved and enabled|机器人配置已保存并启用|ボット設定が保存され、有効になりました/,
+ )
+ .waitFor({ timeout: 15_000 });
+ result.visible_signals.push("bot-created", "adapter-enabled");
+
+ await page.getByRole("button", { name: /Next|下一步|次へ/ }).click();
+ await page
+ .getByText(/Local Agent|本地 Agent/)
+ .first()
+ .click();
+ await page.waitForFunction(
+ () =>
+ Array.from(document.querySelectorAll("textarea")).some((element) => {
+ const value = element.value || "";
+ return [
+ "Welcome new group members",
+ "欢迎新群成员",
+ "新しいグループメンバー",
+ ].some((expected) => value.includes(expected));
+ }),
+ null,
+ { timeout: 15_000 },
+ );
+ result.visible_signals.push("scenario-prompt-visible");
+
+ await page
+ .getByText(/QA Deterministic Runner|QA 确定性 Runner/)
+ .first()
+ .click();
+
+ const agentResponse = page.waitForResponse(
+ (response) =>
+ response.request().method() === "POST" &&
+ /\/api\/v1\/agents$/.test(new URL(response.url()).pathname),
+ );
+ await page
+ .getByRole("button", {
+ name: /Create & Deploy|创建并部署|作成&デプロイ/,
+ })
+ .click();
+ const agentPayload = await (await agentResponse).json();
+ agentId = agentPayload.data?.uuid || "";
+ if (!agentId) throw new Error("Wizard did not create an Agent.");
+ result.agent_id = agentId;
+ result.api.create_agent = { code: agentPayload.code ?? null };
+ await page.getByText(/All Set!|一切就绪!/).waitFor({ timeout: 15_000 });
+ result.visible_signals.push("agent-created", "wizard-complete");
+
+ const savedBot = await api(page, `/api/v1/platform/bots/${botId}`);
+ const route = savedBot.json.data?.bot?.event_bindings?.[0];
+ result.api.saved_route = {
+ http_status: savedBot.status,
+ event_pattern: route?.event_pattern || null,
+ target_type: route?.target_type || null,
+ target_matches: route?.target_uuid === agentId,
+ };
+ if (
+ route?.event_pattern !== "group.member_joined" ||
+ route?.target_type !== "agent" ||
+ route?.target_uuid !== agentId
+ ) {
+ throw new Error("Wizard did not persist the expected Agent route.");
+ }
+ result.visible_signals.push("route-persisted");
+
+ result.runtime.onebot = await runProbe(port);
+ let routeStatus;
+ for (let attempt = 0; attempt < 30; attempt += 1) {
+ routeStatus = await api(
+ page,
+ `/api/v1/platform/bots/${botId}/event-routes/status`,
+ );
+ if (routeStatus.json.data?.routes?.[0]?.last_status === "delivered") break;
+ await page.waitForTimeout(250);
+ }
+ const latest = routeStatus?.json?.data?.routes?.[0];
+ result.runtime.route_status = latest || null;
+ if (latest?.last_status !== "delivered") {
+ throw new Error(
+ `Runtime route did not reach delivered status: ${latest?.last_status || "none"}`,
+ );
+ }
+ result.visible_signals.push(
+ "platform-event-converted",
+ "agent-ran",
+ "reply-delivered",
+ );
+
+ const botUrl = `${frontendUrl.replace(/\/$/, "")}/home/bots?id=${encodeURIComponent(botId)}`;
+ await page.goto(botUrl, { waitUntil: "domcontentloaded" });
+ result.url = page.url();
+ const deliveredStatus = page
+ .getByText(/Delivered|已投递|配信済み/, { exact: true })
+ .first();
+ await deliveredStatus.waitFor({ timeout: 15_000 });
+ await deliveredStatus.scrollIntoViewIfNeeded();
+ await page.waitForTimeout(250);
+ result.visible_signals.push("delivered-status-visible");
+ await safeScreenshot(page, paths.screenshot);
+
+ result.diagnostics = await scanBrowserDiagnostics(paths);
+ if (result.diagnostics.status !== "pass") {
+ throw new Error(result.diagnostics.reason);
+ }
+ result.status = "pass";
+ result.reason =
+ "Quick Start created an enabled OneBot scenario that converted a platform event, ran an Agent, delivered its reply, and showed the final status.";
+} catch (error) {
+ if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
+ result.reason = result.reason || error.message;
+ if (browser?.page) await safeScreenshot(browser.page, paths.screenshot);
+} finally {
+ if (browser?.page && token) {
+ if (botId) {
+ const deleted = await api(
+ browser.page,
+ `/api/v1/platform/bots/${encodeURIComponent(botId)}`,
+ { method: "DELETE" },
+ ).catch(() => ({ status: 0, json: {} }));
+ result.cleanup.bot_deleted =
+ deleted.status < 400 && deleted.json.code === 0;
+ }
+ if (agentId) {
+ const deleted = await api(
+ browser.page,
+ `/api/v1/agents/${encodeURIComponent(agentId)}`,
+ { method: "DELETE" },
+ ).catch(() => ({ status: 0, json: {} }));
+ result.cleanup.agent_deleted =
+ deleted.status < 400 && deleted.json.code === 0;
+ }
+ }
+ const cleanupFailed =
+ (botId && !result.cleanup.bot_deleted) ||
+ (agentId && !result.cleanup.agent_deleted);
+ if (cleanupFailed && result.status === "pass") {
+ result.status = "fail";
+ result.reason = "The temporary OneBot wizard resources were not deleted.";
+ }
+ if (browser) await browser.close().catch(() => {});
+ const finishedAt = new Date();
+ result.finished_at = finishedAt.toISOString();
+ result.finished_at_local = localIsoWithOffset(finishedAt);
+ await writeResult(paths, result);
+ console.log(JSON.stringify(result, null, 2));
+}
+
+process.exit(exitCode(result.status));
diff --git a/skills/skills.index.json b/skills/skills.index.json
index d5bed5995..e9e8a1ebb 100644
--- a/skills/skills.index.json
+++ b/skills/skills.index.json
@@ -191,6 +191,7 @@
"sandbox-skill-authoring-edit-existing-e2e",
"skill-discovery-via-mcp-gateway",
"webui-login-state",
+ "wizard-onebot-agent-runtime",
"wizard-scenario-routing"
],
"case_summaries": [
@@ -1649,6 +1650,32 @@
"console"
]
},
+ {
+ "id": "wizard-onebot-agent-runtime",
+ "title": "Quick Start runs a real OneBot member event through an Agent",
+ "mode": "agent-browser",
+ "area": "wizard",
+ "type": "feature",
+ "priority": "p0",
+ "risk": "high",
+ "ci_eligible": false,
+ "tags": [
+ "wizard",
+ "eba",
+ "onebot",
+ "agent-runner",
+ "release-gate"
+ ],
+ "automation": "scripts/e2e/wizard-onebot-agent-runtime.mjs",
+ "setup_automation": [],
+ "setup_provides_env": [],
+ "evidence_required": [
+ "ui",
+ "screenshot",
+ "console",
+ "api_diagnostic"
+ ]
+ },
{
"id": "wizard-scenario-routing",
"title": "Quick Start filters channels by the selected bot scenario",
@@ -1678,6 +1705,7 @@
"suites": [
"agent-runner-release-gate",
"core-smoke",
+ "eba-release-gate",
"langbot-debug-chat-isolation-gate",
"langbot-debug-chat-load-gate",
"langbot-live-backend-gate",
@@ -1751,6 +1779,24 @@
"local-agent-basic-debug-chat"
]
},
+ {
+ "id": "eba-release-gate",
+ "title": "Event-based bot product release gate",
+ "description": "Browser and runtime gate for scenario onboarding, route diagnostics, and real platform-to-Agent execution.",
+ "type": "release_gate",
+ "priority": "p0",
+ "tags": [
+ "eba",
+ "event-routing",
+ "wizard",
+ "release-gate"
+ ],
+ "cases": [
+ "wizard-scenario-routing",
+ "bot-event-routing-product-flow",
+ "wizard-onebot-agent-runtime"
+ ]
+ },
{
"id": "langbot-debug-chat-isolation-gate",
"title": "LangBot Debug Chat isolation gate",
diff --git a/skills/skills/langbot-testing/cases/wizard-onebot-agent-runtime.yaml b/skills/skills/langbot-testing/cases/wizard-onebot-agent-runtime.yaml
new file mode 100644
index 000000000..220c5b3f5
--- /dev/null
+++ b/skills/skills/langbot-testing/cases/wizard-onebot-agent-runtime.yaml
@@ -0,0 +1,61 @@
+id: wizard-onebot-agent-runtime
+title: "Quick Start runs a real OneBot member event through an Agent"
+mode: agent-browser
+area: wizard
+type: feature
+priority: p0
+risk: high
+ci_eligible: false
+tags:
+ - wizard
+ - eba
+ - onebot
+ - agent-runner
+ - release-gate
+skills:
+ - langbot-env-setup
+ - langbot-testing
+env:
+ - LANGBOT_FRONTEND_URL
+ - LANGBOT_BACKEND_URL
+ - LANGBOT_BROWSER_PROFILE
+automation: scripts/e2e/wizard-onebot-agent-runtime.mjs
+automation_env:
+ - LANGBOT_FRONTEND_URL
+ - LANGBOT_BACKEND_URL
+ - LANGBOT_BROWSER_PROFILE
+ - LANGBOT_CHROMIUM_EXECUTABLE
+preconditions:
+ - "The target is a local test instance where temporary Bots and Agents may be created and deleted."
+ - "Local Agent is installed so the scenario prompt can be verified in its prompt editor."
+ - "The QA Deterministic Runner is installed and registered as plugin:qa/agent-runner/default."
+ - "The LangBot virtualenv includes the websockets package used by the local OneBot probe."
+steps:
+ - "Open Quick Start and choose Welcome new members with the OneBot v11 channel."
+ - "Create and enable a temporary OneBot Bot on an available local port."
+ - "Select Local Agent and confirm the editable welcome prompt is prefilled."
+ - "Choose QA Deterministic Runner and finish the wizard."
+ - "Connect a local OneBot reverse WebSocket and send a group_increase notice."
+ - "Confirm the Agent output is sent back with send_group_msg."
+ - "Open the Bot route editor and confirm the route status is Delivered."
+checks:
+ - "UI: The wizard reaches All Set after creating the Agent route."
+ - "UI: Local Agent receives a scenario-specific editable welcome prompt."
+ - "Runtime: OneBot group_increase converts to group.member_joined."
+ - "Runtime: The saved Agent route reaches delivered status."
+ - "Runtime: The deterministic Agent reply invokes send_group_msg."
+ - "UI: Delivered is visible on the saved route card."
+ - "Console: No unexpected frontend errors appear during the flow."
+ - "Cleanup: The temporary Bot and Agent are deleted."
+evidence_required:
+ - ui
+ - screenshot
+ - console
+ - api_diagnostic
+diagnostics:
+ - "The case uses a local reverse WebSocket and deterministic runner, so it requires no platform or model credentials."
+ - "A passing API route status without the visible Delivered route card is not a pass."
+troubleshooting:
+ - backend-not-listening
+ - plugin-runtime-timeout
+ - proxy-env-mismatch
diff --git a/skills/skills/langbot-testing/suites/eba-release-gate.yaml b/skills/skills/langbot-testing/suites/eba-release-gate.yaml
new file mode 100644
index 000000000..594258808
--- /dev/null
+++ b/skills/skills/langbot-testing/suites/eba-release-gate.yaml
@@ -0,0 +1,14 @@
+id: eba-release-gate
+title: "Event-based bot product release gate"
+description: "Browser and runtime gate for scenario onboarding, route diagnostics, and real platform-to-Agent execution."
+type: release_gate
+priority: p0
+tags:
+ - eba
+ - event-routing
+ - wizard
+ - release-gate
+cases:
+ - wizard-scenario-routing
+ - bot-event-routing-product-flow
+ - wizard-onebot-agent-runtime
diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx
index 6bc9ceb27..28eb2b2db 100644
--- a/web/src/app/wizard/page.tsx
+++ b/web/src/app/wizard/page.tsx
@@ -14,7 +14,6 @@ import {
Loader2,
MessageSquare,
ShieldCheck,
- UserCheck,
UserMinus,
UserPlus,
X,
@@ -34,7 +33,10 @@ import {
Pipeline,
WizardProgress,
} from '@/app/infra/entities/api';
-import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
+import {
+ DynamicFormItemType,
+ IDynamicFormItemSchema,
+} from '@/app/infra/entities/form/dynamic';
import {
PipelineConfigTab,
PipelineConfigStage,
@@ -84,9 +86,14 @@ type WizardScenarioId =
| 'message_reply'
| 'welcome_members'
| 'handle_departures'
- | 'review_friend_requests'
| 'handle_moderation';
+const WIZARD_SCENARIO_PROMPT_KEYS: Partial