mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-16 17:36:07 +00:00
feat(wizard): add scenario templates and EBA release gate
This commit is contained in:
@@ -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))
|
||||||
@@ -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));
|
||||||
@@ -191,6 +191,7 @@
|
|||||||
"sandbox-skill-authoring-edit-existing-e2e",
|
"sandbox-skill-authoring-edit-existing-e2e",
|
||||||
"skill-discovery-via-mcp-gateway",
|
"skill-discovery-via-mcp-gateway",
|
||||||
"webui-login-state",
|
"webui-login-state",
|
||||||
|
"wizard-onebot-agent-runtime",
|
||||||
"wizard-scenario-routing"
|
"wizard-scenario-routing"
|
||||||
],
|
],
|
||||||
"case_summaries": [
|
"case_summaries": [
|
||||||
@@ -1649,6 +1650,32 @@
|
|||||||
"console"
|
"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",
|
"id": "wizard-scenario-routing",
|
||||||
"title": "Quick Start filters channels by the selected bot scenario",
|
"title": "Quick Start filters channels by the selected bot scenario",
|
||||||
@@ -1678,6 +1705,7 @@
|
|||||||
"suites": [
|
"suites": [
|
||||||
"agent-runner-release-gate",
|
"agent-runner-release-gate",
|
||||||
"core-smoke",
|
"core-smoke",
|
||||||
|
"eba-release-gate",
|
||||||
"langbot-debug-chat-isolation-gate",
|
"langbot-debug-chat-isolation-gate",
|
||||||
"langbot-debug-chat-load-gate",
|
"langbot-debug-chat-load-gate",
|
||||||
"langbot-live-backend-gate",
|
"langbot-live-backend-gate",
|
||||||
@@ -1751,6 +1779,24 @@
|
|||||||
"local-agent-basic-debug-chat"
|
"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",
|
"id": "langbot-debug-chat-isolation-gate",
|
||||||
"title": "LangBot Debug Chat isolation gate",
|
"title": "LangBot Debug Chat isolation gate",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
+23
-15
@@ -14,7 +14,6 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
UserCheck,
|
|
||||||
UserMinus,
|
UserMinus,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
X,
|
X,
|
||||||
@@ -34,7 +33,10 @@ import {
|
|||||||
Pipeline,
|
Pipeline,
|
||||||
WizardProgress,
|
WizardProgress,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
import {
|
||||||
|
DynamicFormItemType,
|
||||||
|
IDynamicFormItemSchema,
|
||||||
|
} from '@/app/infra/entities/form/dynamic';
|
||||||
import {
|
import {
|
||||||
PipelineConfigTab,
|
PipelineConfigTab,
|
||||||
PipelineConfigStage,
|
PipelineConfigStage,
|
||||||
@@ -84,9 +86,14 @@ type WizardScenarioId =
|
|||||||
| 'message_reply'
|
| 'message_reply'
|
||||||
| 'welcome_members'
|
| 'welcome_members'
|
||||||
| 'handle_departures'
|
| 'handle_departures'
|
||||||
| 'review_friend_requests'
|
|
||||||
| 'handle_moderation';
|
| 'handle_moderation';
|
||||||
|
|
||||||
|
const WIZARD_SCENARIO_PROMPT_KEYS: Partial<Record<WizardScenarioId, string>> = {
|
||||||
|
welcome_members: 'wizard.scenario.welcomeMembersPrompt',
|
||||||
|
handle_departures: 'wizard.scenario.handleDeparturesPrompt',
|
||||||
|
handle_moderation: 'wizard.scenario.handleModerationPrompt',
|
||||||
|
};
|
||||||
|
|
||||||
const WIZARD_SCENARIOS = [
|
const WIZARD_SCENARIOS = [
|
||||||
{
|
{
|
||||||
id: 'message_reply' as const,
|
id: 'message_reply' as const,
|
||||||
@@ -115,15 +122,6 @@ const WIZARD_SCENARIOS = [
|
|||||||
icon: UserMinus,
|
icon: UserMinus,
|
||||||
emoji: '👤',
|
emoji: '👤',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'review_friend_requests' as const,
|
|
||||||
eventType: 'friend.request_received',
|
|
||||||
processorKind: 'agent' as const,
|
|
||||||
labelKey: 'wizard.scenario.reviewFriendRequests',
|
|
||||||
descriptionKey: 'wizard.scenario.reviewFriendRequestsDescription',
|
|
||||||
icon: UserCheck,
|
|
||||||
emoji: '✅',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'handle_moderation' as const,
|
id: 'handle_moderation' as const,
|
||||||
eventType: 'group.member_banned',
|
eventType: 'group.member_banned',
|
||||||
@@ -363,10 +361,20 @@ export default function WizardPage() {
|
|||||||
(runner: string) => {
|
(runner: string) => {
|
||||||
setSelectedRunner(runner);
|
setSelectedRunner(runner);
|
||||||
const configStage = aiConfigTab?.stages.find((s) => s.name === runner);
|
const configStage = aiConfigTab?.stages.find((s) => s.name === runner);
|
||||||
setRunnerConfig(configStage ? getDefaultValues(configStage.config) : {});
|
const defaults = configStage ? getDefaultValues(configStage.config) : {};
|
||||||
|
const promptKey = selectedScenario
|
||||||
|
? WIZARD_SCENARIO_PROMPT_KEYS[selectedScenario]
|
||||||
|
: undefined;
|
||||||
|
const supportsPromptEditor = configStage?.config.some(
|
||||||
|
(item) => item.type === DynamicFormItemType.PROMPT_EDITOR,
|
||||||
|
);
|
||||||
|
if (promptKey && supportsPromptEditor) {
|
||||||
|
defaults.prompt = [{ role: 'system', content: t(promptKey) }];
|
||||||
|
}
|
||||||
|
setRunnerConfig(defaults);
|
||||||
saveProgress({ step: 2, selected_runner: runner });
|
saveProgress({ step: 2, selected_runner: runner });
|
||||||
},
|
},
|
||||||
[aiConfigTab, saveProgress],
|
[aiConfigTab, saveProgress, selectedScenario, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Navigation helpers ----
|
// ---- Navigation helpers ----
|
||||||
@@ -981,7 +989,7 @@ function StepPlatform({
|
|||||||
{t('wizard.scenario.description')}
|
{t('wizard.scenario.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{WIZARD_SCENARIOS.map((scenario) => {
|
{WIZARD_SCENARIOS.map((scenario) => {
|
||||||
const Icon = scenario.icon;
|
const Icon = scenario.icon;
|
||||||
const isSelected = selectedScenario === scenario.id;
|
const isSelected = selectedScenario === scenario.id;
|
||||||
|
|||||||
@@ -1936,15 +1936,18 @@ const enUS = {
|
|||||||
'Answer incoming private or group messages with an AI Pipeline.',
|
'Answer incoming private or group messages with an AI Pipeline.',
|
||||||
welcomeMembers: 'Welcome new members',
|
welcomeMembers: 'Welcome new members',
|
||||||
welcomeMembersDescription: 'Run an Agent when someone joins a group.',
|
welcomeMembersDescription: 'Run an Agent when someone joins a group.',
|
||||||
|
welcomeMembersPrompt:
|
||||||
|
'Welcome new group members with a short, friendly message. Use the available member and group context when present. Do not mention internal event names or system details.',
|
||||||
handleDepartures: 'Handle member departures',
|
handleDepartures: 'Handle member departures',
|
||||||
handleDeparturesDescription:
|
handleDeparturesDescription:
|
||||||
'Run an Agent when someone leaves or is removed.',
|
'Run an Agent when someone leaves or is removed.',
|
||||||
reviewFriendRequests: 'Review friend requests',
|
handleDeparturesPrompt:
|
||||||
reviewFriendRequestsDescription:
|
'Respond to group member departures with a brief, respectful message when a public response is appropriate. Do not speculate about why the member left or mention internal event names.',
|
||||||
'Let an Agent decide how to handle new friend requests.',
|
|
||||||
handleModeration: 'Handle moderation events',
|
handleModeration: 'Handle moderation events',
|
||||||
handleModerationDescription:
|
handleModerationDescription:
|
||||||
'Run an Agent when a group member is restricted.',
|
'Run an Agent when a group member is restricted.',
|
||||||
|
handleModerationPrompt:
|
||||||
|
'Write a concise, neutral group notice about the member restriction using only the available context. Do not invent details or mention internal event names.',
|
||||||
pipelineBadge: 'Pipeline',
|
pipelineBadge: 'Pipeline',
|
||||||
agentBadge: 'Agent',
|
agentBadge: 'Agent',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1858,15 +1858,18 @@ const jaJP = {
|
|||||||
welcomeMembers: '新しいメンバーを歓迎',
|
welcomeMembers: '新しいメンバーを歓迎',
|
||||||
welcomeMembersDescription:
|
welcomeMembersDescription:
|
||||||
'メンバーがグループに参加したときに Agent を実行します。',
|
'メンバーがグループに参加したときに Agent を実行します。',
|
||||||
|
welcomeMembersPrompt:
|
||||||
|
'新しいグループメンバーを短く親しみやすいメッセージで歓迎してください。利用可能なメンバーとグループの情報を活用し、内部イベント名やシステムの詳細には言及しないでください。',
|
||||||
handleDepartures: 'メンバーの退出を処理',
|
handleDepartures: 'メンバーの退出を処理',
|
||||||
handleDeparturesDescription:
|
handleDeparturesDescription:
|
||||||
'メンバーが退出または削除されたときに Agent を実行します。',
|
'メンバーが退出または削除されたときに Agent を実行します。',
|
||||||
reviewFriendRequests: '友だち申請を確認',
|
handleDeparturesPrompt:
|
||||||
reviewFriendRequestsDescription:
|
'公開の応答が適切な場合に、メンバーの退出へ短く敬意のあるメッセージで対応してください。退出理由を推測したり、内部イベント名に言及したりしないでください。',
|
||||||
'新しい申請の処理方法を Agent に判断させます。',
|
|
||||||
handleModeration: 'モデレーションイベントを処理',
|
handleModeration: 'モデレーションイベントを処理',
|
||||||
handleModerationDescription:
|
handleModerationDescription:
|
||||||
'グループメンバーが制限されたときに Agent を実行します。',
|
'グループメンバーが制限されたときに Agent を実行します。',
|
||||||
|
handleModerationPrompt:
|
||||||
|
'利用可能な情報だけを使い、メンバーへの制限について簡潔で中立的なグループ通知を書いてください。詳細を作り上げたり、内部イベント名に言及したりしないでください。',
|
||||||
pipelineBadge: 'Pipeline',
|
pipelineBadge: 'Pipeline',
|
||||||
agentBadge: 'Agent',
|
agentBadge: 'Agent',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1855,12 +1855,16 @@ const zhHans = {
|
|||||||
messageReplyDescription: '使用 AI Pipeline 回复私聊或群聊消息。',
|
messageReplyDescription: '使用 AI Pipeline 回复私聊或群聊消息。',
|
||||||
welcomeMembers: '欢迎新成员',
|
welcomeMembers: '欢迎新成员',
|
||||||
welcomeMembersDescription: '有人加入群组时运行 Agent。',
|
welcomeMembersDescription: '有人加入群组时运行 Agent。',
|
||||||
|
welcomeMembersPrompt:
|
||||||
|
'用简短、友好的消息欢迎新群成员。有成员和群组上下文时请合理使用,不要提及内部事件名称或系统细节。',
|
||||||
handleDepartures: '处理成员离群',
|
handleDepartures: '处理成员离群',
|
||||||
handleDeparturesDescription: '有人离开或被移出群组时运行 Agent。',
|
handleDeparturesDescription: '有人离开或被移出群组时运行 Agent。',
|
||||||
reviewFriendRequests: '审核好友请求',
|
handleDeparturesPrompt:
|
||||||
reviewFriendRequestsDescription: '让 Agent 决定如何处理新的好友请求。',
|
'当适合公开回应时,用简短、尊重的消息处理群成员离开。不要猜测成员离开的原因,也不要提及内部事件名称。',
|
||||||
handleModeration: '处理群管理事件',
|
handleModeration: '处理群管理事件',
|
||||||
handleModerationDescription: '群成员受到限制时运行 Agent。',
|
handleModerationDescription: '群成员受到限制时运行 Agent。',
|
||||||
|
handleModerationPrompt:
|
||||||
|
'仅根据现有上下文,写一条简洁、中立的群管理通知,说明成员受到的限制。不要编造细节或提及内部事件名称。',
|
||||||
pipelineBadge: 'Pipeline',
|
pipelineBadge: 'Pipeline',
|
||||||
agentBadge: 'Agent',
|
agentBadge: 'Agent',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user