fix(agent): harden runner integration and QA

This commit is contained in:
huanghuoguoguo
2026-08-01 09:23:41 +08:00
parent 55f42a4ebc
commit 79611a5513
38 changed files with 1315 additions and 132 deletions
@@ -6,6 +6,7 @@ import { resolve } from "node:path";
import { env } from "node:process";
import {
createBrowser,
ensureBrowserWorkspace,
ensureEvidence,
evidencePaths,
exitCode,
@@ -124,6 +125,12 @@ async function run() {
const { page } = browser;
await page.goto(frontendUrl, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
const workspace = await ensureBrowserWorkspace(page, backendUrl);
if (workspace.status !== "pass") {
result.status = workspace.status;
result.reason = workspace.reason;
return;
}
const diagnostic = await page.evaluate(async ({ backendUrl, targets, testModels }) => {
const blockers = [];
@@ -150,6 +157,7 @@ async function run() {
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "",
};
const getJson = async (path) => {
const response = await fetch(`${backendUrl}${path}`, { headers });
@@ -78,6 +78,14 @@ try {
provider: "claude-code",
location: "remote-ssh",
workspace: remoteWorkspace,
"env-json": JSON.stringify({
ALL_PROXY: "",
all_proxy: "",
HTTP_PROXY: "",
http_proxy: "",
HTTPS_PROXY: "",
https_proxy: "",
}),
"ssh-target": sshTarget,
"ssh-port": Number.parseInt(sshPort, 10),
"ssh-identity-file": sshIdentityFile,
@@ -4,6 +4,7 @@ import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { env } from "node:process";
import {
authenticatedApiHeaders,
apiJson,
ensureEvidence,
evidencePaths,
@@ -99,7 +100,15 @@ try {
}
result.store_task_id = store.json.data?.task_id || "";
const ready = await waitForSentinel(backendUrl, auth.token, result.kb_uuid, query, expectedText, waitMs);
const ready = await waitForSentinel(
backendUrl,
auth.token,
result.kb_uuid,
query,
expectedText,
waitMs,
upload.fileId,
);
result.file_statuses = ready.fileStatuses;
if (ready.matched) {
result.checked_bases.push(ready.checked);
@@ -209,9 +218,7 @@ async function uploadDocument(backendUrl, token, path) {
form.append("file", new Blob([bytes], { type: "text/plain" }), "sentinel-doc.txt");
const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/files/documents`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
headers: await authenticatedApiHeaders(backendUrl, token, { contentType: "" }),
body: form,
});
const json = await response.json().catch(() => ({}));
@@ -222,13 +229,14 @@ async function uploadDocument(backendUrl, token, path) {
return { fileId };
}
async function waitForSentinel(backendUrl, token, kbUuid, query, expectedText, timeoutMs) {
async function waitForSentinel(backendUrl, token, kbUuid, query, expectedText, timeoutMs, uploadedFileId) {
const started = Date.now();
let fileStatuses = [];
let lastChecked = null;
while (Date.now() - started < timeoutMs) {
const files = await apiJson(backendUrl, `/api/v1/knowledge/bases/${encodeURIComponent(kbUuid)}/files`, { token });
fileStatuses = files.json.data?.files || fileStatuses;
const listedFiles = files.json.data?.files || [];
fileStatuses = listedFiles.filter((item) => item.file_name === uploadedFileId);
lastChecked = await retrieveSentinel(backendUrl, token, kbUuid, kbName, query, expectedText);
if (lastChecked.matched) {
return { matched: true, fileStatuses, checked: lastChecked };
+6 -23
View File
@@ -4,9 +4,12 @@ import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { env } from "node:process";
import {
authenticatedApiHeaders,
apiJson,
ensureEvidence,
evidencePaths,
isTaskComplete,
isTaskFailed,
loadEnvFiles,
resetAndAuthLocalUser,
writeResult,
@@ -138,7 +141,7 @@ async function installPlugin(backendUrl, token, bytes, packagePath) {
form.set("file", new Blob([bytes]), packagePath.split("/").pop());
const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
headers: await authenticatedApiHeaders(backendUrl, token, { contentType: "" }),
body: form,
});
const json = await response.json().catch(() => ({}));
@@ -173,7 +176,7 @@ async function previewPackage(backendUrl, token, bytes, packagePath) {
form.set("file", new Blob([bytes]), packagePath.split("/").pop());
const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local/preview`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
headers: await authenticatedApiHeaders(backendUrl, token, { contentType: "" }),
body: form,
});
const json = await response.json().catch(() => ({}));
@@ -214,32 +217,12 @@ async function waitForTask(backendUrl, token, taskId) {
while (Date.now() < deadline) {
const response = await apiJson(backendUrl, `/api/v1/system/tasks/${encodeURIComponent(taskId)}`, { token });
last = response.json.data || response.json;
if (isTaskComplete(last) || isTaskFailed(last)) return last;
if (isTaskFailed(last) || isTaskComplete(last)) return last;
await sleep(1000);
}
return last;
}
function isTaskComplete(task) {
const status = String(task?.status || task?.state || "").toLowerCase();
const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase();
return ["done", "completed", "success", "succeeded", "finished"].includes(status)
|| ["done", "completed", "success", "succeeded", "finished"].includes(runtimeStatus)
|| task?.done === true
|| task?.completed === true
|| (task?.runtime?.done === true && !task?.runtime?.exception);
}
function isTaskFailed(task) {
const status = String(task?.status || task?.state || "").toLowerCase();
const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase();
return ["failed", "error", "cancelled", "canceled"].includes(status)
|| ["failed", "error", "cancelled", "canceled"].includes(runtimeStatus)
|| task?.failed === true
|| Boolean(task?.error)
|| Boolean(task?.runtime?.exception);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -3,6 +3,7 @@
import {
bodyText,
createBrowser,
ensureBrowserWorkspace,
ensureEvidence,
evidencePaths,
exitCode,
@@ -57,6 +58,12 @@ try {
const { page } = browser;
await page.goto(`${frontendUrl.replace(/\/$/, "")}/home/knowledge`, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
const workspace = await ensureBrowserWorkspace(page, backendUrl);
if (workspace.status !== "pass") {
result.status = workspace.status;
result.reason = workspace.reason;
throw new Error(result.reason);
}
result.url = page.url();
const text = await bodyText(page);
@@ -77,6 +84,7 @@ try {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "",
},
body: JSON.stringify({ query }),
});
+61 -15
View File
@@ -424,18 +424,33 @@ async function fetchDebugChatHistory(page, { backendUrl, pipelineId, sessionType
}
return await page.evaluate(async ({ backendUrl, pipelineId, sessionType }) => {
const token = localStorage.getItem("token") || "";
const response = await fetch(
`${backendUrl.replace(/\/$/, "")}/api/v1/pipelines/${encodeURIComponent(pipelineId)}/ws/messages/${encodeURIComponent(sessionType)}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
);
const json = await response.json().catch(() => ({}));
return {
status: response.ok && json.code === 0 ? "ready" : "fail",
http_status: response.status,
code: json.code ?? null,
messages: json.data?.messages || [],
reason: response.ok && json.code === 0 ? "" : json.msg || `Debug Chat history returned HTTP ${response.status}.`,
};
try {
const response = await fetch(
`${backendUrl.replace(/\/$/, "")}/api/v1/pipelines/${encodeURIComponent(pipelineId)}/ws/messages/${encodeURIComponent(sessionType)}`,
{
headers: token
? {
Authorization: `Bearer ${token}`,
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "",
}
: {},
},
);
const json = await response.json().catch(() => ({}));
return {
status: response.ok && json.code === 0 ? "ready" : "fail",
http_status: response.status,
code: json.code ?? null,
messages: json.data?.messages || [],
reason: response.ok && json.code === 0 ? "" : json.msg || `Debug Chat history returned HTTP ${response.status}.`,
};
} catch (error) {
return {
status: "retryable",
messages: [],
reason: `Debug Chat history request failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
}, { backendUrl, pipelineId, sessionType });
}
@@ -454,6 +469,10 @@ async function waitForFinalDebugChatAssistant(page, {
while (Date.now() < deadline) {
lastHistory = await fetchDebugChatHistory(page, { backendUrl, pipelineId, sessionType });
if (lastHistory.status === "fail") return lastHistory;
if (lastHistory.status === "retryable") {
await page.waitForTimeout(Math.min(250, Math.max(1, deadline - Date.now())));
continue;
}
const assistants = lastHistory.messages.filter((message) => message.role === "assistant");
const latest = assistants.at(-1);
if (assistants.length > beforeAssistantCount && latest?.is_final === true) {
@@ -480,12 +499,23 @@ export async function attachDebugChatImage(page, imagePath) {
if (!await input.count()) {
return { status: "fail", reason: "Could not find a Debug Chat image upload input." };
}
const previews = page.locator('[data-debug-chat-attachment-preview="true"]');
const beforePreviewCount = await previews.count();
await input.setInputFiles(imagePath);
await page.locator("img").last().waitFor({ state: "visible", timeout: 10_000 }).catch(() => {});
const previewVisible = await previews
.nth(beforePreviewCount)
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (!previewVisible) {
return { status: "fail", reason: "Debug Chat did not show the selected image preview." };
}
return { status: "ready", reason: `Attached image fixture: ${imagePath}` };
}
export async function sendDebugChatPrompt(page, prompt, imagePath = "") {
const sentImages = page.locator('[data-debug-chat-message-image="true"]');
const beforeSentImageCount = imagePath ? await sentImages.count() : 0;
const imageResult = await attachDebugChatImage(page, imagePath);
if (imageResult.status === "fail") return imageResult;
@@ -500,6 +530,16 @@ export async function sendDebugChatPrompt(page, prompt, imagePath = "") {
const clickedSend = await clickFirstVisible(page, ["Send", "发送", "提交"], 1_500);
if (!clickedSend) await page.keyboard.press("Enter");
await page.getByText(prompt, { exact: false }).last().waitFor({ state: "visible", timeout: 10_000 }).catch(() => {});
if (imagePath) {
const sentImageVisible = await sentImages
.nth(beforeSentImageCount)
.waitFor({ state: "visible", timeout: 15_000 })
.then(() => true)
.catch(() => false);
if (!sentImageVisible) {
return { status: "fail", reason: "The sent Debug Chat message did not render its image attachment." };
}
}
return true;
}
@@ -535,7 +575,7 @@ export async function runDebugChatPrompt(page, {
}
const responseStartedAt = Date.now();
await waitForExpectedDebugChatText(page, {
const expectedTextPromise = waitForExpectedDebugChatText(page, {
expectedText,
expectedTexts: requiredExpectedTexts,
minExpectedCount,
@@ -545,13 +585,19 @@ export async function runDebugChatPrompt(page, {
beforeText,
failureSignals,
});
const finalAssistant = await waitForFinalDebugChatAssistant(page, {
const finalAssistantPromise = waitForFinalDebugChatAssistant(page, {
backendUrl,
pipelineId,
sessionType,
beforeAssistantCount: beforeHistoryAssistantCount,
timeoutMs: Math.max(1, responseTimeoutMs - (Date.now() - responseStartedAt)),
});
if (backendUrl && pipelineId && sessionType) {
await Promise.race([expectedTextPromise, finalAssistantPromise]);
} else {
await expectedTextPromise;
}
const finalAssistant = await finalAssistantPromise;
await waitForDebugChatTextStable(page);
const afterText = await bodyText(page);
+184 -3
View File
@@ -1,8 +1,11 @@
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { basename, join, resolve } from "node:path";
import { env } from "node:process";
const secretRe = /(?:authorization|bearer|token|secret|password|api[_-]?key|jwt|oauth)\s*[:=]\s*["']?[^"',\s]+/gi;
const ACTIVE_WORKSPACE_STORAGE_KEY = "langbot_active_workspace_uuid";
const workspaceUuidCache = new Map();
export function redact(text) {
return String(text ?? "")
@@ -30,6 +33,19 @@ export function localIsoWithOffset(date = new Date()) {
return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}.${ms}${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`;
}
export function boxWorkspaceNamespace(instanceUuid, workspaceUuid) {
const instance = String(instanceUuid || "").trim();
const workspace = String(workspaceUuid || "").trim();
if (!instance || !workspace) {
throw new Error("Box Workspace namespace requires instance and Workspace UUIDs.");
}
const digest = createHash("sha256")
.update(`${instance}\0${workspace}`, "utf8")
.digest("hex")
.slice(0, 24);
return `ws-${digest}`;
}
export function evidencePaths(caseId) {
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join("reports", "evidence", runId));
@@ -103,6 +119,27 @@ export async function writeResult(paths, result) {
}
}
export function isTaskFailed(task) {
const status = String(task?.status || task?.state || "").toLowerCase();
const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase();
return ["failed", "error", "cancelled", "canceled"].includes(status)
|| ["failed", "error", "cancelled", "canceled"].includes(runtimeStatus)
|| task?.failed === true
|| Boolean(task?.error)
|| Boolean(task?.runtime?.exception);
}
export function isTaskComplete(task) {
if (isTaskFailed(task)) return false;
const status = String(task?.status || task?.state || "").toLowerCase();
const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase();
return ["done", "completed", "success", "succeeded", "finished"].includes(status)
|| ["done", "completed", "success", "succeeded", "finished"].includes(runtimeStatus)
|| task?.done === true
|| task?.completed === true
|| task?.runtime?.done === true;
}
function browserDiagnosticFindings(source, text) {
const findings = [];
const lines = String(text || "").split(/\r?\n/);
@@ -231,11 +268,98 @@ export async function readRecoveryKey(repo = env.LANGBOT_REPO || "") {
return match?.[1] || "";
}
export async function apiJson(backendUrl, path, { method = "GET", token = "", body } = {}) {
function workspaceCacheKey(backendUrl, token) {
return `${backendUrl.replace(/\/$/, "")}\0${token}`;
}
function isAccountScopedApi(path, method) {
const pathname = path.split("?", 1)[0];
if (pathname === "/api/v1/workspaces/bootstrap") return true;
if (pathname === "/api/v1/workspaces" && method === "GET") return true;
return [
"/api/v1/user/check-token",
"/api/v1/user/info",
"/api/v1/user/account-info",
"/api/v1/user/change-password",
].includes(pathname);
}
export async function resolveWorkspaceUuid(
backendUrl,
token,
preferredWorkspaceUuid = env.LANGBOT_WORKSPACE_UUID || "",
) {
if (!token) throw new Error("A user token is required to resolve the active Workspace.");
const normalizedBackendUrl = backendUrl.replace(/\/$/, "");
const normalizedPreferred = preferredWorkspaceUuid.trim();
const cacheKey = workspaceCacheKey(normalizedBackendUrl, token);
const cached = workspaceUuidCache.get(cacheKey);
if (cached && (!normalizedPreferred || cached === normalizedPreferred)) return cached;
const response = await fetch(`${normalizedBackendUrl}/api/v1/workspaces/bootstrap`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
const json = await response.json().catch(() => ({}));
if (response.status >= 400 || json.code !== 0) {
throw new Error(json.msg || `Workspace bootstrap failed with HTTP ${response.status}.`);
}
const workspaces = json.data?.workspaces || [];
const workspaceUuids = workspaces
.map((entry) => entry.workspace?.uuid || entry.uuid || "")
.filter(Boolean);
let workspaceUuid = normalizedPreferred;
if (workspaceUuid && !workspaceUuids.includes(workspaceUuid)) {
throw new Error(`Configured Workspace ${workspaceUuid} is not available to the authenticated Account.`);
}
if (!workspaceUuid && workspaceUuids.length === 1) {
[workspaceUuid] = workspaceUuids;
}
if (!workspaceUuid && workspaceUuids.length === 0) {
throw new Error("The authenticated Account has no available Workspace.");
}
if (!workspaceUuid) {
throw new Error(
"The authenticated Account has multiple Workspaces; set LANGBOT_WORKSPACE_UUID for this QA run.",
);
}
workspaceUuidCache.set(cacheKey, workspaceUuid);
return workspaceUuid;
}
export async function authenticatedApiHeaders(
backendUrl,
token,
{ contentType = "application/json", workspaceUuid = "" } = {},
) {
const resolvedWorkspaceUuid = await resolveWorkspaceUuid(backendUrl, token, workspaceUuid);
return {
Authorization: `Bearer ${token}`,
...(contentType ? { "Content-Type": contentType } : {}),
"X-Workspace-Id": resolvedWorkspaceUuid,
};
}
export async function apiJson(
backendUrl,
path,
{ method = "GET", token = "", body, skipWorkspace = false, workspaceUuid = "" } = {},
) {
const normalizedMethod = method.toUpperCase();
const headers = { "Content-Type": "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
if (token) {
headers.Authorization = `Bearer ${token}`;
if (!skipWorkspace && !isAccountScopedApi(path, normalizedMethod)) {
headers["X-Workspace-Id"] = await resolveWorkspaceUuid(backendUrl, token, workspaceUuid);
}
}
const response = await fetch(`${backendUrl.replace(/\/$/, "")}${path}`, {
method,
method: normalizedMethod,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
@@ -301,6 +425,38 @@ export async function setBrowserToken(page, frontendUrl, token) {
await page.evaluate((value) => localStorage.setItem("token", value), token);
}
export async function ensureBrowserWorkspace(
page,
backendUrl,
preferredWorkspaceUuid = env.LANGBOT_WORKSPACE_UUID || "",
) {
const browserContext = await page.evaluate((storageKey) => ({
token: localStorage.getItem("token") || "",
workspaceUuid: localStorage.getItem(storageKey) || "",
}), ACTIVE_WORKSPACE_STORAGE_KEY);
if (!browserContext.token) {
return { status: "blocked", reason: "Browser profile has no localStorage token.", workspace_uuid: "" };
}
try {
const workspaceUuid = await resolveWorkspaceUuid(
backendUrl,
browserContext.token,
preferredWorkspaceUuid || browserContext.workspaceUuid,
);
await page.evaluate(({ storageKey, workspaceUuid: value }) => {
localStorage.setItem(storageKey, value);
}, { storageKey: ACTIVE_WORKSPACE_STORAGE_KEY, workspaceUuid });
return {
status: "pass",
reason: "Browser Workspace selection is initialized.",
workspace_uuid: workspaceUuid,
};
} catch (error) {
return { status: "blocked", reason: error.message, workspace_uuid: "" };
}
}
export async function verifyBrowserToken(page, backendUrl) {
return await page.evaluate(async (baseUrl) => {
const token = localStorage.getItem("token");
@@ -349,11 +505,23 @@ export async function ensureAuthenticatedBrowser(page, {
reason: error.message,
}));
if (current.authenticated) {
const workspace = await ensureBrowserWorkspace(page, backendUrl);
if (workspace.status !== "pass") {
return {
status: workspace.status,
reason: workspace.reason,
backend_token_check: null,
browser_token_check: current,
workspace,
injected: false,
};
}
return {
status: "pass",
reason: "Existing browser token is valid.",
backend_token_check: null,
browser_token_check: current,
workspace,
injected: false,
};
}
@@ -381,11 +549,24 @@ export async function ensureAuthenticatedBrowser(page, {
};
}
const workspace = await ensureBrowserWorkspace(page, backendUrl);
if (workspace.status !== "pass") {
return {
status: workspace.status,
reason: workspace.reason,
backend_token_check: auth.check,
browser_token_check: browserCheck,
workspace,
injected: true,
};
}
return {
status: "pass",
reason: "Browser token injected from local recovery login.",
backend_token_check: auth.check,
browser_token_check: browserCheck,
workspace,
injected: true,
};
}
@@ -161,7 +161,8 @@ try {
result.status = resetDiagnostic.status;
result.reason = resetDiagnostic.reason || "Debug Chat reset failed.";
} else {
await page.waitForTimeout(1000);
await page.reload({ waitUntil: "commit" });
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
const reopenResult = await openPipelineDebugChat(page, {
pipelineUrl,
pipelineName,
@@ -406,6 +407,7 @@ async function inspectPipeline(page, { backendUrl, pipelineUrl, pipelineName, ex
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "",
},
});
return {
@@ -508,6 +510,7 @@ async function inspectToolNames(page, { backendUrl }) {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "",
},
});
const json = await response.json().catch(() => ({}));
@@ -545,6 +548,7 @@ async function resetPipelineDebugChat(page, { backendUrl, pipelineId, sessionTyp
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "",
},
},
);
+4 -11
View File
@@ -35,22 +35,15 @@ await ensureEvidence(paths);
const startedAt = new Date();
const fixturePath = resolve(env.LANGBOT_MCP_FIXTURE_PATH || "skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py");
const langbotRepo = env.LANGBOT_REPO ? resolve(env.LANGBOT_REPO) : "";
const uvCandidates = [
env.LANGBOT_MCP_FIXTURE_UV,
"uv",
].filter(Boolean);
const uv = uvCandidates.find((candidate) => candidate === "uv" || existsSync(candidate));
const pythonCandidates = [
env.LANGBOT_MCP_FIXTURE_PYTHON,
langbotRepo ? `${langbotRepo}/.venv/bin/python` : "",
"python3",
].filter(Boolean);
const python = pythonCandidates.find((candidate) => candidate === "python3" || existsSync(candidate));
const command = langbotRepo && uv
? { executable: uv, args: ["run", "python", fixturePath], cwd: langbotRepo, mode: "uv" }
: python
? { executable: python, args: [fixturePath], cwd: resolve("."), mode: "python" }
: null;
const command = python
? { executable: python, args: [fixturePath], cwd: resolve("."), mode: "python" }
: null;
const expectedText = "qa_mcp_echo:mcp-stdio-fixture-ok";
const result = {
@@ -94,7 +87,7 @@ async function request(child, id, method, params) {
async function run() {
if (!command) {
result.status = "env_issue";
result.reason = "No uv or Python interpreter found. Set LANGBOT_REPO, LANGBOT_MCP_FIXTURE_UV, or LANGBOT_MCP_FIXTURE_PYTHON.";
result.reason = "No Python interpreter found. Set LANGBOT_REPO or LANGBOT_MCP_FIXTURE_PYTHON.";
return;
}
if (!existsSync(fixturePath)) {
+20 -1
View File
@@ -6,6 +6,7 @@ import { resolve } from "node:path";
import { env } from "node:process";
import {
createBrowser,
ensureBrowserWorkspace,
ensureEvidence,
evidencePaths,
exitCode,
@@ -91,6 +92,12 @@ async function run() {
const { page } = browser;
await page.goto(env.LANGBOT_FRONTEND_URL, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
const workspace = await ensureBrowserWorkspace(page, backendUrl);
if (workspace.status !== "pass") {
result.status = workspace.status;
result.reason = workspace.reason;
return;
}
const diagnostic = await page.evaluate(async ({
backendUrl,
@@ -101,6 +108,7 @@ async function run() {
fixtureArgs,
startupTimeoutSec,
readyTimeoutMs,
workspaceUuid,
}) => {
const token = localStorage.getItem("token");
if (!token) {
@@ -120,6 +128,7 @@ async function run() {
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id": workspaceUuid,
};
const serverConfig = {
name: serverName,
@@ -193,7 +202,17 @@ async function run() {
runtime_tool_count: lastRuntime?.tool_count ?? null,
runtime_error: lastRuntime?.error_message || "",
};
}, { backendUrl, serverName, expectedTool, fixturePath, fixtureCommand, fixtureArgs, startupTimeoutSec, readyTimeoutMs });
}, {
backendUrl,
serverName,
expectedTool,
fixturePath,
fixtureCommand,
fixtureArgs,
startupTimeoutSec,
readyTimeoutMs,
workspaceUuid: workspace.workspace_uuid,
});
await writeFile(apiDiagnosticPath, `${JSON.stringify(diagnostic, null, 2)}\n`, "utf8");
await safeScreenshot(page, paths.screenshot);
+7 -1
View File
@@ -401,6 +401,7 @@ async function inspectAndPatchPipelineConfig(page, {
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "",
};
const getJson = async (path) => {
const response = await fetch(`${backendUrl}${path}`, { headers });
@@ -607,6 +608,7 @@ async function restorePipelineConfig(page, { backendUrl, pipelineId, config }) {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "",
},
body: JSON.stringify({ config }),
});
@@ -645,6 +647,7 @@ async function inspectAndPatchPipelineExtensions(page, {
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "",
};
const getJson = async (path) => {
const response = await fetch(`${backendUrl}${path}`, { headers });
@@ -800,6 +803,7 @@ async function restorePipelineExtensions(page, { backendUrl, pipelineId, extensi
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "",
},
body: JSON.stringify(extensions),
});
@@ -836,6 +840,7 @@ async function resetPipelineDebugChat(page, { backendUrl, pipelineId, sessionTyp
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "",
},
},
);
@@ -965,7 +970,8 @@ try {
result.status = resetDiagnostic.status;
result.reason = resetDiagnostic.reason || "Debug Chat reset failed.";
} else {
await page.waitForTimeout(1000);
await page.reload({ waitUntil: "commit" });
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
const reopenResult = await openPipelineDebugChat(page, {
pipelineUrl,
pipelineName,
@@ -1,17 +1,23 @@
#!/usr/bin/env node
import { cp, mkdir, rm } from "node:fs/promises";
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { env } from "node:process";
import { spawnSync } from "node:child_process";
import {
apiJson,
boxWorkspaceNamespace,
ensureEvidence,
evidencePaths,
loadEnvFiles,
resetAndAuthLocalUser,
resolveWorkspaceUuid,
writeResult,
} from "./lib/langbot-e2e.mjs";
const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026";
await loadEnvFiles();
const paths = evidencePaths("reset-complex-agent-task");
await ensureEvidence(paths);
@@ -22,7 +28,7 @@ const source = resolve(
"../../skills/langbot-testing/fixtures/complex-agent-task/workspace",
);
const repo = env.LANGBOT_REPO || "";
const target = repo ? resolve(repo, "data/box/default/order-orchestrator") : "";
let target = "";
const result = {
source: "setup_automation",
case_id: "reset-complex-agent-task",
@@ -36,6 +42,32 @@ const result = {
try {
if (!repo) throw new Error("LANGBOT_REPO is required.");
const backendUrl = env.LANGBOT_BACKEND_URL || "";
const user = env.LANGBOT_E2E_LOGIN_USER || "";
const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD;
if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is required.");
if (!user) throw new Error("LANGBOT_E2E_LOGIN_USER is required.");
const auth = await resetAndAuthLocalUser({ backendUrl, user, password });
const workspaceUuid = await resolveWorkspaceUuid(backendUrl, auth.token);
const bootstrap = await apiJson(backendUrl, "/api/v1/workspaces/bootstrap", {
token: auth.token,
skipWorkspace: true,
});
const workspaceAccess = (bootstrap.json.data?.workspaces || []).find(
(entry) => (entry.workspace?.uuid || entry.uuid) === workspaceUuid,
);
const instanceUuid = workspaceAccess?.workspace?.instance_uuid || workspaceAccess?.instance_uuid || "";
if (!instanceUuid) throw new Error("Workspace bootstrap did not include instance_uuid.");
const boxWorkspace = env.LANGBOT_BOX_WORKSPACE_HOST_PATH || resolve(
repo,
"data/box/default/tenants",
boxWorkspaceNamespace(instanceUuid, workspaceUuid),
);
target = resolve(boxWorkspace, "order-orchestrator");
result.target = target;
result.workspace_uuid = workspaceUuid;
await rm(target, { recursive: true, force: true });
await mkdir(dirname(target), { recursive: true });
await cp(source, target, { recursive: true });
@@ -50,6 +82,10 @@ try {
if (baseline.error) throw baseline.error;
if (baseline.status === 0) throw new Error("Complex task baseline unexpectedly passes; the fixture must start failing.");
await upsertEnvLocal(resolve("skills/.env.local"), {
LANGBOT_COMPLEX_AGENT_WORKSPACE: target,
});
result.status = "pass";
result.reason = "Complex task workspace reset and failing baseline confirmed.";
} catch (error) {
@@ -60,3 +96,20 @@ try {
await writeResult(paths, result);
console.log(JSON.stringify(result, null, 2));
process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1);
async function upsertEnvLocal(path, updates) {
let content = "";
try {
content = await readFile(path, "utf8");
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
const lines = content ? content.split(/\r?\n/) : [];
for (const [key, value] of Object.entries(updates)) {
const replacement = `${key}=${value}`;
const index = lines.findIndex((line) => line.startsWith(`${key}=`));
if (index >= 0) lines[index] = replacement;
else lines.push(replacement);
}
await writeFile(path, `${lines.filter(Boolean).join("\n")}\n`, "utf8");
}
@@ -81,6 +81,7 @@ async function api(page, path, options = {}) {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "",
},
body:
options.body === undefined ? undefined : JSON.stringify(options.body),
+56 -1
View File
@@ -156,6 +156,8 @@
"agent-runner-runtime-chaos",
"bot-event-routing-product-flow",
"box-mcp-heartbeat-recovery",
"claude-code-agent-debug-chat",
"codex-agent-debug-chat",
"dify-agent-debug-chat",
"langbot-fake-provider-debug-chat-cross-pipeline-isolation",
"langbot-fake-provider-debug-chat-fault-recovery",
@@ -601,6 +603,58 @@
"filesystem"
]
},
{
"id": "claude-code-agent-debug-chat",
"title": "Claude Code Agent can retrieve LangBot history through MCP",
"mode": "agent-browser",
"area": "pipeline",
"type": "regression",
"priority": "p2",
"risk": "high",
"ci_eligible": false,
"tags": [
"agent-runner",
"claude-code",
"external-runner",
"mcp",
"pipeline"
],
"automation": "scripts/e2e/pipeline-debug-chat.mjs",
"setup_automation": [],
"setup_provides_env": [],
"evidence_required": [
"ui",
"screenshot",
"console",
"backend_log"
]
},
{
"id": "codex-agent-debug-chat",
"title": "Codex Agent can retrieve LangBot history through MCP",
"mode": "agent-browser",
"area": "pipeline",
"type": "regression",
"priority": "p2",
"risk": "high",
"ci_eligible": false,
"tags": [
"agent-runner",
"codex",
"external-runner",
"mcp",
"pipeline"
],
"automation": "scripts/e2e/pipeline-debug-chat.mjs",
"setup_automation": [],
"setup_provides_env": [],
"evidence_required": [
"ui",
"screenshot",
"console",
"backend_log"
]
},
{
"id": "dify-agent-debug-chat",
"title": "Dify AgentRunner returns a response through Pipeline Debug Chat",
@@ -1097,7 +1151,8 @@
],
"setup_provides_env": [
"LANGBOT_LOCAL_AGENT_PIPELINE_URL",
"LANGBOT_LOCAL_AGENT_PIPELINE_NAME"
"LANGBOT_LOCAL_AGENT_PIPELINE_NAME",
"LANGBOT_COMPLEX_AGENT_WORKSPACE"
],
"evidence_required": [
"ui",
@@ -0,0 +1,72 @@
id: claude-code-agent-debug-chat
title: "Claude Code Agent can retrieve LangBot history through MCP"
mode: agent-browser
area: pipeline
type: regression
priority: p2
risk: high
ci_eligible: false
tags:
- agent-runner
- claude-code
- external-runner
- mcp
- pipeline
skills:
- langbot-env-setup
- langbot-testing
env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
env_any:
- LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_URL|LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_NAME
automation: scripts/e2e/pipeline-debug-chat.mjs
automation_env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_BROWSER_PROFILE
- LANGBOT_CHROMIUM_EXECUTABLE
- LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_URL
- LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_NAME
automation_pipeline_url_env: LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot-team/ClaudeCodeAgent/default"
automation_reset_debug_chat: "1"
automation_prompts_json: '[{"prompt":"Remember this exact passcode for the LangBot conversation: claude-history-proof-6384. Do not repeat the passcode. Reply exactly CLAUDE_HISTORY_SEEDED with no other text.","expected_text":"CLAUDE_HISTORY_SEEDED","response_timeout_ms":"600000"},{"prompt":"This CLI turn has no reused Claude session. Recover the passcode from LangBot history by calling mcp__langbot_agent__langbot_history_page exactly once. If ToolSearch is available here, first load the tool with select:mcp__langbot_agent__langbot_history_page and call it directly. If this is a coordinator session that exposes only delegation tools, delegate to exactly one worker and require that worker to perform the same ToolSearch-then-call flow, then wait for its result. Use only the returned LangBot history. If the MCP call fails, do not guess or claim success. Reply with only the recovered passcode.","expected_text":"claude-history-proof-6384","response_timeout_ms":"600000"}]'
automation_expected_text: "claude-history-proof-6384"
automation_response_timeout_ms: "600000"
preconditions:
- "Claude Code CLI is installed, authenticated, and runnable non-interactively by the LangBot plugin process."
- "The configured workspace exists and is writable by the Claude Code CLI."
- "The pipeline has reuse-session=false so the second turn cannot use Claude CLI session memory."
steps:
- "Open LANGBOT_FRONTEND_URL."
- "Open the Claude Code Agent QA pipeline."
- "Confirm the pipeline runner is plugin:langbot-team/ClaudeCodeAgent/default."
- "Open Debug Chat, seed the passcode, then ask a fresh Claude CLI turn to recover it through mcp__langbot_agent__langbot_history_page."
checks:
- "UI: The first Bot message contains CLAUDE_HISTORY_SEEDED and the second contains claude-history-proof-6384."
- "Runner evidence: The second run records a real LangBot history MCP tool call in the main session or one delegated worker, not only model text."
- "Logs: The request completes without claude_code process, MCP, approval, or runtime errors."
- "Console: No unexpected frontend errors appear during Debug Chat."
evidence_required:
- ui
- screenshot
- console
- backend_log
success_patterns:
- "CLAUDE_HISTORY_SEEDED"
- "claude-history-proof-6384"
- "Streaming completed"
failure_patterns:
- "claude_code.command_not_found"
- "claude_code.process_exited"
- "not available among the connected MCP tools"
- "not accessible in this session"
- "No such tool available"
- "Agent runner not found"
- "Agent runner plugin:langbot-team/ClaudeCodeAgent/default execution failed"
troubleshooting:
- backend-not-listening
- plugin-runtime-timeout
- proxy-env-mismatch
@@ -0,0 +1,71 @@
id: codex-agent-debug-chat
title: "Codex Agent can retrieve LangBot history through MCP"
mode: agent-browser
area: pipeline
type: regression
priority: p2
risk: high
ci_eligible: false
tags:
- agent-runner
- codex
- external-runner
- mcp
- pipeline
skills:
- langbot-env-setup
- langbot-testing
env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
env_any:
- LANGBOT_CODEX_AGENT_PIPELINE_URL|LANGBOT_CODEX_AGENT_PIPELINE_NAME
automation: scripts/e2e/pipeline-debug-chat.mjs
automation_env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_BROWSER_PROFILE
- LANGBOT_CHROMIUM_EXECUTABLE
- LANGBOT_CODEX_AGENT_PIPELINE_URL
- LANGBOT_CODEX_AGENT_PIPELINE_NAME
automation_pipeline_url_env: LANGBOT_CODEX_AGENT_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_CODEX_AGENT_PIPELINE_NAME
automation_expected_runner_id: "plugin:langbot-team/CodexAgent/default"
automation_reset_debug_chat: "1"
automation_prompts_json: '[{"prompt":"Do not launch a subagent. Remember this exact passcode for the LangBot conversation: codex-history-proof-9157. Do not repeat the passcode. Reply exactly CODEX_HISTORY_SEEDED with no other text.","expected_text":"CODEX_HISTORY_SEEDED","response_timeout_ms":"600000"},{"prompt":"Do not launch a subagent. This CLI turn has no reused Codex session. Directly call the langbot_history_page tool exposed by the langbot_agent MCP server exactly once and wait for its result. Use only the returned LangBot history to recover the passcode from the previous user turn. If the tool is unavailable, do not guess or claim success. Reply with only the recovered passcode.","expected_text":"codex-history-proof-9157","response_timeout_ms":"600000"}]'
automation_expected_text: "codex-history-proof-9157"
automation_response_timeout_ms: "600000"
preconditions:
- "Codex CLI is installed, authenticated, and runnable non-interactively by the LangBot plugin process."
- "The configured workspace exists and is writable, with approval policy never and sandbox mode appropriate for the QA host."
- "The pipeline has reuse-session=false so the second turn cannot use Codex CLI thread memory."
steps:
- "Open LANGBOT_FRONTEND_URL."
- "Open the Codex Agent QA pipeline."
- "Confirm the pipeline runner is plugin:langbot-team/CodexAgent/default."
- "Open Debug Chat, seed the passcode, then ask a fresh Codex CLI turn to recover it through langbot_agent.langbot_history_page."
checks:
- "UI: The first Bot message contains CODEX_HISTORY_SEEDED and the second contains codex-history-proof-9157."
- "Runner evidence: The second run records a real LangBot history MCP tool call, not only model text."
- "Logs: The request completes without codex process, MCP, approval, sandbox, or runtime errors."
- "Console: No unexpected frontend errors appear during Debug Chat."
evidence_required:
- ui
- screenshot
- console
- backend_log
success_patterns:
- "CODEX_HISTORY_SEEDED"
- "codex-history-proof-9157"
- "Streaming completed"
failure_patterns:
- "codex.command_not_found"
- "codex.process_exited"
- "not available among the connected MCP tools"
- "not accessible in this session"
- "Agent runner not found"
- "Agent runner plugin:langbot-team/CodexAgent/default execution failed"
troubleshooting:
- backend-not-listening
- plugin-runtime-timeout
- proxy-env-mismatch
@@ -44,15 +44,16 @@ automation_prompt: "Work autonomously on the project at /workspace/order-orchest
automation_expected_text: "COMPLEX_AGENT_TASK_OK tests=12 acceptance=PASS"
automation_response_timeout_ms: "900000"
automation_stream_output: "1"
automation_filesystem_checks_json: '[{"argv":["python3","skills/skills/langbot-testing/fixtures/complex-agent-task/verify.py","--workspace","${LANGBOT_REPO}/data/box/default/order-orchestrator"],"cwd":"${LANGBOT_REPO}","stdout_contains":"HOST_VERIFY_PASS tests=12 acceptance=PASS protected=PASS","exit_code":0,"timeout_ms":120000}]'
automation_filesystem_checks_json: '[{"argv":["python3","skills/skills/langbot-testing/fixtures/complex-agent-task/verify.py","--workspace","${LANGBOT_COMPLEX_AGENT_WORKSPACE}"],"cwd":"${LANGBOT_REPO}","stdout_contains":"HOST_VERIFY_PASS tests=12 acceptance=PASS protected=PASS","exit_code":0,"timeout_ms":120000}]'
setup_automation:
- "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env"
- "node:scripts/e2e/reset-complex-agent-task.mjs"
setup_provides_env:
- LANGBOT_LOCAL_AGENT_PIPELINE_URL
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
- LANGBOT_COMPLEX_AGENT_WORKSPACE
preconditions:
- "The local Box workspace is mounted at LANGBOT_REPO/data/box/default and exposed to native tools as /workspace."
- "The active tenant Box workspace is available on the host and exposed to native tools as /workspace."
- "The selected real model supports sustained function calling across file inspection, edits, and test reruns."
steps:
- "Reset the isolated order-orchestrator workspace and confirm its baseline test suite fails."
+97 -2
View File
@@ -59,16 +59,56 @@ import {
waitForDebugChatReady,
} from "../scripts/e2e/lib/debug-chat.mjs";
import {
apiJson,
beginBackendLogCapture,
boxWorkspaceNamespace,
clickFirstVisible,
ensureAuthenticatedBrowser,
finishBackendLogCapture,
isTaskComplete,
isTaskFailed,
resolveLangBotRepo,
scanBrowserDiagnostics,
} from "../scripts/e2e/lib/langbot-e2e.mjs";
const root = process.cwd();
test("Box workspace namespace matches the SDK tenancy contract", () => {
assert.equal(
boxWorkspaceNamespace(
"instance_8e83c14e-db31-4746-97f4-2dc165136a18",
"29907f84-54a8-4da9-903f-04d84ff8793a",
),
"ws-8410680088facf21327b7542",
);
});
test("e2e task helpers reject finished tasks with runtime exceptions", () => {
const task = {
runtime: {
done: true,
state: "FINISHED",
exception: "ValueError: plugin not found",
},
};
assert.equal(isTaskFailed(task), true);
assert.equal(isTaskComplete(task), false);
});
test("e2e task helpers accept successful finished tasks", () => {
const task = {
runtime: {
done: true,
state: "FINISHED",
exception: null,
},
};
assert.equal(isTaskFailed(task), false);
assert.equal(isTaskComplete(task), true);
});
test("clickFirstVisible waits for a later visible DOM match", async () => {
let pollCount = 0;
let clickedIndex = -1;
@@ -218,19 +258,36 @@ test("e2e helper can inject a local login token into a fresh browser context", a
{ status: auth === "Bearer fresh-token" ? 200 : 401 },
);
}
if (url.endsWith("/api/v1/workspaces/bootstrap")) {
return new Response(
JSON.stringify({
code: 0,
data: { workspaces: [{ workspace: { uuid: "workspace-test" } }] },
}),
{ status: 200 },
);
}
return new Response(JSON.stringify({ code: -1, msg: "unexpected" }), {
status: 404,
});
}) as typeof fetch;
let token = "";
let workspaceUuid = "";
const page = {
addInitScript: async () => {},
goto: async () => {},
evaluate: async (fn: unknown, arg: string) => {
evaluate: async (fn: unknown, arg: unknown) => {
const text = String(fn);
if (text.includes("workspaceUuid: localStorage.getItem")) {
return { token, workspaceUuid };
}
if (text.includes("workspaceUuid: value")) {
workspaceUuid = (arg as { workspaceUuid: string }).workspaceUuid;
return undefined;
}
if (text.includes("localStorage.setItem")) {
token = arg;
token = String(arg);
return undefined;
}
if (text.includes("localStorage.getItem")) {
@@ -263,6 +320,7 @@ test("e2e helper can inject a local login token into a fresh browser context", a
assert.equal(result.status, "pass");
assert.equal(result.injected, true);
assert.equal(token, "fresh-token");
assert.equal(workspaceUuid, "workspace-test");
} finally {
globalThis.fetch = previousFetch;
if (previousRepo === undefined) delete process.env.LANGBOT_REPO;
@@ -271,6 +329,43 @@ test("e2e helper can inject a local login token into a fresh browser context", a
}
});
test("apiJson bootstraps and sends the selected Workspace for scoped APIs", async () => {
const previousFetch = globalThis.fetch;
const requests: Array<{ url: string; headers: Record<string, string> }> = [];
try {
globalThis.fetch = (async (url: string, init?: RequestInit) => {
const headers = (init?.headers || {}) as Record<string, string>;
requests.push({ url, headers });
if (url.endsWith("/api/v1/workspaces/bootstrap")) {
return new Response(
JSON.stringify({
code: 0,
data: { workspaces: [{ workspace: { uuid: "workspace-api-test" } }] },
}),
{ status: 200 },
);
}
return new Response(JSON.stringify({ code: 0, data: { tools: [] } }), {
status: 200,
});
}) as typeof fetch;
const response = await apiJson(
"http://127.0.0.1:5300",
"/api/v1/tools",
{ token: "workspace-api-token" },
);
assert.equal(response.status, 200);
assert.equal(requests.length, 2);
assert.equal(requests[0].url, "http://127.0.0.1:5300/api/v1/workspaces/bootstrap");
assert.equal(requests[0].headers["X-Workspace-Id"], undefined);
assert.equal(requests[1].headers["X-Workspace-Id"], "workspace-api-test");
} finally {
globalThis.fetch = previousFetch;
}
});
function ctx(args: string[]): CommandContext {
return { root, args };
}
+14 -1
View File
@@ -235,6 +235,15 @@ class AgentRunnerRegistry:
runners = await self.list_runners(context, bound_plugins=None)
descriptor = next((item for item in runners if item.id == runner_id), None)
if descriptor is None:
# The runtime launches installed plugins asynchronously, so an
# early non-empty discovery can still be only a partial snapshot.
runners = await self.list_runners(
context,
bound_plugins=None,
use_cache=False,
)
descriptor = next((item for item in runners if item.id == runner_id), None)
if descriptor is None:
raise RunnerNotFoundError(runner_id)
@@ -255,7 +264,11 @@ class AgentRunnerRegistry:
Returns runner options and their config schemas for the DynamicForm.
"""
# Get all runners (no bound plugin filter for metadata listing)
runners = await self.list_runners(context, bound_plugins=None)
runners = await self.list_runners(
context,
bound_plugins=None,
use_cache=False,
)
options = []
stages = []
+5
View File
@@ -559,6 +559,11 @@ class BoxService:
namespace = box_namespace(self._action_context(context))
return os.path.join(self.default_workspace, 'tenants', namespace)
def workspace_host_path(self, context: TenantContext) -> str | None:
"""Return the host path mounted as /workspace for one execution context."""
return self._tenant_workspace(context)
async def execute_spec_payload(
self,
spec_payload: dict,
@@ -0,0 +1,149 @@
"""repair an ownerless local Workspace after tenancy migration
Revision ID: 0017_local_owner_repair
Revises: 0016_agent_workspace
Create Date: 2026-07-31
"""
from __future__ import annotations
import datetime
import uuid
import sqlalchemy as sa
from alembic import op
revision = '0017_local_owner_repair'
down_revision = '0016_agent_workspace'
branch_labels = None
depends_on = None
def _table_names(conn: sa.Connection) -> set[str]:
return set(sa.inspect(conn).get_table_names())
def upgrade() -> None:
conn = op.get_bind()
required = {'metadata', 'users', 'workspaces', 'workspace_memberships'}
if not required.issubset(_table_names(conn)):
return
metadata = sa.table(
'metadata',
sa.column('key', sa.String(255)),
sa.column('value', sa.String(255)),
)
instance_uuid = conn.execute(
sa.select(metadata.c.value).where(metadata.c.key == 'instance_uuid')
).scalar_one_or_none()
if not isinstance(instance_uuid, str) or not instance_uuid.strip():
return
workspaces = sa.table(
'workspaces',
sa.column('uuid', sa.String(36)),
sa.column('instance_uuid', sa.String(255)),
sa.column('source', sa.String(32)),
sa.column('created_by_account_uuid', sa.String(36)),
)
workspace_uuids = conn.execute(
sa.select(workspaces.c.uuid).where(
workspaces.c.instance_uuid == instance_uuid.strip(),
workspaces.c.source == 'local',
)
).scalars().all()
if not workspace_uuids:
return
if len(workspace_uuids) > 1:
raise RuntimeError(f'Multiple local Workspaces exist for instance {instance_uuid!r}')
workspace_uuid = workspace_uuids[0]
if conn.dialect.name == 'postgresql':
conn.execute(
sa.text("SELECT set_config('langbot.workspace_uuid', :workspace_uuid, true)"),
{'workspace_uuid': workspace_uuid},
)
memberships = sa.table(
'workspace_memberships',
sa.column('uuid', sa.String(36)),
sa.column('workspace_uuid', sa.String(36)),
sa.column('account_uuid', sa.String(36)),
sa.column('role', sa.String(32)),
sa.column('status', sa.String(32)),
sa.column('joined_at', sa.DateTime()),
sa.column('projection_revision', sa.BigInteger()),
)
active_owner = conn.execute(
sa.select(memberships.c.account_uuid).where(
memberships.c.workspace_uuid == workspace_uuid,
memberships.c.role == 'owner',
memberships.c.status == 'active',
)
).scalar_one_or_none()
if active_owner is not None:
conn.execute(
workspaces.update()
.where(workspaces.c.uuid == workspace_uuid)
.where(workspaces.c.created_by_account_uuid.is_(None))
.values(created_by_account_uuid=active_owner)
)
return
users = sa.table(
'users',
sa.column('id', sa.Integer()),
sa.column('uuid', sa.String(36)),
sa.column('status', sa.String(32)),
)
owner_account_uuid = conn.execute(
sa.select(users.c.uuid)
.where(users.c.status == 'active')
.order_by(users.c.id)
.limit(1)
).scalar_one_or_none()
if owner_account_uuid is None:
return
now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
membership = conn.execute(
sa.select(memberships.c.uuid, memberships.c.joined_at).where(
memberships.c.workspace_uuid == workspace_uuid,
memberships.c.account_uuid == owner_account_uuid,
)
).first()
if membership is None:
conn.execute(
memberships.insert().values(
uuid=str(uuid.uuid4()),
workspace_uuid=workspace_uuid,
account_uuid=owner_account_uuid,
role='owner',
status='active',
joined_at=now,
projection_revision=0,
)
)
else:
conn.execute(
memberships.update()
.where(memberships.c.uuid == membership.uuid)
.values(
role='owner',
status='active',
joined_at=membership.joined_at or now,
)
)
conn.execute(
workspaces.update()
.where(workspaces.c.uuid == workspace_uuid)
.values(created_by_account_uuid=owner_account_uuid)
)
def downgrade() -> None:
# The repair restores a required invariant; downgrading the revision should
# not deliberately recreate an ownerless Workspace.
pass
+1 -3
View File
@@ -180,9 +180,7 @@ class Controller:
)
self.ap.query_pool.condition.notify_all()
continue
if selected_query: # 找到
queries.remove(selected_query)
else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
if selected_query is None: # 找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
await self.ap.query_pool.condition.wait()
continue
@@ -537,7 +537,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
Image / Voice / File components uploaded from the web client carry a
storage key in ``path``. Resolve it to a base64 data URI so downstream
stages (multimodal LLM input and the Box sandbox inbox) have a usable
payload, then drop the now-consumed storage object.
payload. Keep the storage key for browser history; the configured
storage-retention cleanup removes expired uploads.
Args:
message_chain_obj: 消息链对象列表
@@ -592,12 +593,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream'
component['base64'] = f'data:{mime_type};base64,{base64_str}'
await storage_mgr.delete_scoped_object_key(
execution_context,
comp_path,
expected_owner_type='upload_image',
)
component['path'] = ''
except Exception as e:
await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}')
raise
+14 -11
View File
@@ -994,7 +994,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
self._connected.clear()
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is not None:
with contextlib.suppress(Exception):
with contextlib.suppress(Exception, asyncio.CancelledError):
await runtime_handler.close()
if getattr(self, 'handler', None) is runtime_handler:
del self.handler
@@ -1015,7 +1015,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
del self.handler_task
close_ctrl = getattr(getattr(self, 'ctrl', None), 'close', None)
if close_ctrl is not None:
with contextlib.suppress(Exception):
with contextlib.suppress(Exception, asyncio.CancelledError):
await close_ctrl()
async def aclose(self) -> None:
@@ -1717,6 +1717,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
except Exception:
await self._delete_artifact_if_unreferenced(execution_context, artifact_digest)
raise
if not previous_was_durable and self.runtime_profile == 'oss_dev':
bridge = self._legacy_oss_bridge_binding(execution_context)
try:
with runtime_handler.installation_scope(bridge):
async for _ in runtime_handler.delete_plugin(plugin_author, plugin_name):
pass
except Exception as exc:
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
runtime_handler.register_installation_binding(
binding,
plugin_author=plugin_author,
@@ -1731,14 +1739,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
self._workspace_installations.setdefault(binding.workspace_uuid, set()).add(binding.installation_uuid)
if previous_digest is not None and previous_digest != artifact_digest:
await self._delete_artifact_if_unreferenced(execution_context, previous_digest)
if previous_digest is not None and not previous_was_durable and self.runtime_profile == 'oss_dev':
bridge = self._legacy_oss_bridge_binding(execution_context)
try:
with runtime_handler.installation_scope(bridge):
async for _ in runtime_handler.delete_plugin(plugin_author, plugin_name):
pass
except Exception as exc:
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
await self._refresh_agent_runner_registry()
@@ -2213,7 +2213,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
)
if not isinstance(workspace_id, str) or not workspace_id.strip():
raise ValueError('AgentRunner execution requires a Workspace')
await self.require_workspace_context(workspace_id)
execution_context = await self._current_execution_context()
if workspace_id.strip() != execution_context.workspace_uuid:
raise WorkspaceNotFoundError('Plugin resource not found')
await self.require_workspace_context(execution_context)
binding = await self._target_binding(
plugin_author,
plugin_name,
+23 -5
View File
@@ -474,6 +474,7 @@ _RUNTIME_SCOPED_ACTIONS = frozenset(
RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value,
}
)
_OUTBOUND_INSTALLATION_CONTEXT_UNSET = object()
class RuntimeConnectionHandler(handler.Handler):
@@ -873,10 +874,12 @@ class RuntimeConnectionHandler(handler.Handler):
):
super().__init__(connection, disconnect_callback)
self.ap = ap
self._outbound_installation_context: contextvars.ContextVar[InstallationBinding | None] = (
self._outbound_installation_context: contextvars.ContextVar[
InstallationBinding | None | object
] = (
contextvars.ContextVar(
f'{self.__class__.__name__}_{id(self)}_outbound_installation',
default=None,
default=_OUTBOUND_INSTALLATION_CONTEXT_UNSET,
)
)
self._installation_bindings: dict[
@@ -2075,15 +2078,27 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM)
async def get_knowledge_file_stream(data: dict[str, Any]) -> handler.ActionResponse:
action_context, _ = await self._require_plugin_action_context()
action_context, identity = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
installation_binding = InstallationBinding(
instance_uuid=action_context.instance_uuid,
workspace_uuid=action_context.workspace_uuid,
placement_generation=action_context.placement_generation,
installation_uuid=identity.installation_uuid,
runtime_revision=identity.runtime_revision,
artifact_digest=identity.artifact_digest,
)
storage_path = data['storage_path']
try:
content_bytes = await self.ap.rag_runtime_service.get_file_stream(
execution_context,
storage_path,
)
file_key = await self.send_file(content_bytes, '')
file_key = await self.send_file(
content_bytes,
'',
action_context=installation_binding,
)
return handler.ActionResponse.success(data={'file_key': file_key})
except Exception as e:
return _make_rag_error_response(e, 'FileServiceError', storage_path=storage_path)
@@ -2427,10 +2442,13 @@ class RuntimeConnectionHandler(handler.Handler):
) -> InstallationBinding | ActionContext | None:
if action_context is not None:
return super().resolve_outbound_action_context(action_context)
scoped_context = self._outbound_installation_context.get()
if scoped_context is not _OUTBOUND_INSTALLATION_CONTEXT_UNSET:
return typing.cast(InstallationBinding | None, scoped_context)
inbound_context = self.current_action_context
if inbound_context is not None:
return inbound_context
return self._outbound_installation_context.get()
return None
def require_outbound_installation_context(self) -> InstallationBinding:
binding = self._outbound_installation_context.get()
@@ -487,10 +487,15 @@ class BoxStdioSessionRuntime:
)
def _shared_workspace_host_path(self) -> str:
default_workspace = getattr(self.ap.box_service, 'default_workspace', None)
if not default_workspace:
raise RuntimeError('Box default workspace is required for shared MCP host_path staging')
shared_host_path = normalize_host_path(default_workspace)
workspace_host_path = getattr(self.ap.box_service, 'workspace_host_path', None)
if callable(workspace_host_path):
shared_workspace = workspace_host_path(self.owner.execution_context)
else:
# Compatibility for older BoxService embedders used by plugins and tests.
shared_workspace = getattr(self.ap.box_service, 'default_workspace', None)
if not shared_workspace:
raise RuntimeError('Box Workspace host path is required for shared MCP host_path staging')
shared_host_path = normalize_host_path(shared_workspace)
os.makedirs(shared_host_path, exist_ok=True)
return shared_host_path
@@ -149,6 +149,35 @@ async def test_workspace_upgrade_is_idempotent_and_preserves_identifiers(legacy_
assert workspace_uuid_after == workspace_uuid_before
async def test_workspace_upgrade_repairs_ownerless_existing_local_workspace(legacy_engine):
await run_alembic_upgrade(legacy_engine, '0016_agent_workspace')
async with legacy_engine.begin() as conn:
owner_account_uuid = await conn.scalar(sa.text('SELECT uuid FROM users ORDER BY id LIMIT 1'))
workspace_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
await conn.execute(sa.text('DELETE FROM workspace_memberships'))
await conn.execute(
sa.text('UPDATE workspaces SET created_by_account_uuid = NULL WHERE uuid = :workspace_uuid'),
{'workspace_uuid': workspace_uuid},
)
await run_alembic_upgrade(legacy_engine, 'head')
async with legacy_engine.connect() as conn:
workspace = (
await conn.execute(
sa.text('SELECT created_by_account_uuid FROM workspaces WHERE uuid = :workspace_uuid'),
{'workspace_uuid': workspace_uuid},
)
).mappings().one()
membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
assert workspace['created_by_account_uuid'] == owner_account_uuid
assert membership['workspace_uuid'] == workspace_uuid
assert membership['account_uuid'] == owner_account_uuid
assert membership['role'] == 'owner'
assert membership['status'] == 'active'
async def test_workspace_kernel_upgrade_downgrade_upgrade_round_trip(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-round-trip.db"}')
try:
+44
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.agent.runner.registry import AgentRunnerRegistry
@@ -210,6 +212,27 @@ class TestRegistryGet:
assert exc_info.value.runner_id == 'plugin:notexist/unknown/default'
@pytest.mark.asyncio
async def test_get_refreshes_partial_startup_cache_on_miss(self):
"""A runner initialized after early discovery should become available."""
ap = FakeApplication()
ap.plugin_connector.list_agent_runners = AsyncMock(
side_effect=ap.plugin_connector.list_agent_runners,
)
registry = AgentRunnerRegistry(ap)
await registry.list_runners(TEST_CONTEXT)
cache = registry._cache[('instance-test', 'workspace-test', 1)]
cache.pop('plugin:alice/my-agent/custom')
descriptor = await registry.get(
TEST_CONTEXT,
'plugin:alice/my-agent/custom',
)
assert descriptor.id == 'plugin:alice/my-agent/custom'
assert ap.plugin_connector.list_agent_runners.await_count == 2
@pytest.mark.asyncio
async def test_get_runner_with_bound_plugins_filter(self):
"""Get runner with bound plugins authorization."""
@@ -257,6 +280,27 @@ class TestRegistryMetadataForPipeline:
assert stages[0]['config'][0]['type'] == 'string'
assert stages[0]['config'][0]['id'] == 'plugin:alice/my-agent/custom.param1'
@pytest.mark.asyncio
async def test_metadata_refreshes_partial_startup_cache(self):
"""Pipeline metadata should not preserve an early partial discovery."""
ap = FakeApplication()
ap.plugin_connector.list_agent_runners = AsyncMock(
side_effect=ap.plugin_connector.list_agent_runners,
)
registry = AgentRunnerRegistry(ap)
await registry.list_runners(TEST_CONTEXT)
cache = registry._cache[('instance-test', 'workspace-test', 1)]
cache.pop('plugin:alice/my-agent/custom')
options, _ = await registry.get_runner_metadata_for_pipeline(TEST_CONTEXT)
assert {item['name'] for item in options} == {
'plugin:langbot-team/LocalAgent/default',
'plugin:alice/my-agent/custom',
}
assert ap.plugin_connector.list_agent_runners.await_count == 2
class TestDescriptorValidation:
"""Tests for descriptor validation."""
+74 -1
View File
@@ -1,13 +1,15 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.agent.runner.errors import RunnerNotFoundError
from langbot.pkg.pipeline.controller import Controller
from langbot.pkg.pipeline.pool import QueryPool
def make_app():
@@ -77,3 +79,74 @@ async def test_try_claim_steering_sets_pipeline_context_before_claiming():
assert query.pipeline_config is pipeline.pipeline_entity.config
assert query.variables['_pipeline_bound_plugins'] == ['test/runner']
app.agent_run_orchestrator.try_claim_steering_from_query.assert_awaited_once_with(query)
@pytest.mark.asyncio
async def test_consumer_transfers_query_from_queue_to_running_task():
app = make_app()
app.query_pool = QueryPool()
session = SimpleNamespace(_semaphore=asyncio.Semaphore(1))
app.sess_mgr.get_session = AsyncMock(return_value=session)
app.persistence_mgr = SimpleNamespace(mode=SimpleNamespace(value='oss'))
app.workspace_service = SimpleNamespace(
get_execution_binding=AsyncMock(
return_value=SimpleNamespace(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
)
)
)
runtime_pipeline = SimpleNamespace(run=AsyncMock())
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=runtime_pipeline)
worker_tasks = []
task_created = asyncio.Event()
def create_task(coro, **_kwargs):
task = asyncio.create_task(coro)
worker_tasks.append(task)
task_created.set()
return task
app.task_mgr = SimpleNamespace(create_task=create_task)
query = Mock()
query.query_id = 0
query.bot_uuid = 'bot-test'
query.pipeline_uuid = 'pipeline-test'
context = ExecutionContext(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
)
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query', return_value=query):
query = await app.query_pool.add_query(
bot_uuid='bot-test',
launcher_type=Mock(),
launcher_id='launcher-test',
sender_id='sender-test',
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
pipeline_uuid='pipeline-test',
execution_context=context,
)
controller = Controller(app)
consumer_task = asyncio.create_task(controller.consumer())
try:
await asyncio.wait_for(task_created.wait(), timeout=1)
await asyncio.wait_for(worker_tasks[0], timeout=1)
finally:
consumer_task.cancel()
with pytest.raises(asyncio.CancelledError):
await consumer_task
runtime_pipeline.run.assert_awaited_once_with(query)
assert app.query_pool.queries == []
assert app.query_pool.cached_queries == {}
assert app.query_pool.active_query_count_by_workspace == {}
assert session._semaphore._value == 1
assert controller.semaphore._value == 10
app.logger.error.assert_not_called()
@@ -2,9 +2,9 @@
The web debug client uploads Image / Voice / File components carrying a storage
key in ``path``. This helper resolves each to a base64 data URI (so multimodal
LLM input and the Box sandbox inbox have usable bytes), then deletes the
consumed storage object and clears ``path``. Covers mimetype selection per
type and fail-closed error handling.
LLM input and the Box sandbox inbox have usable bytes) while retaining the key
for browser history. Covers mimetype selection per type and fail-closed error
handling.
"""
from __future__ import annotations
@@ -52,7 +52,7 @@ def _make_adapter(load_return=b'hello', load_side_effect=None):
@pytest.mark.asyncio
async def test_image_jpeg_mimetype_and_cleanup():
async def test_image_jpeg_mimetype_and_retained_storage_key():
adapter, storage_mgr, _ = _make_adapter(load_return=b'\xff\xd8\xff')
path = f'{_UPLOAD_PREFIX}photo.jpg'
chain = [{'type': 'Image', 'path': path}]
@@ -61,12 +61,24 @@ async def test_image_jpeg_mimetype_and_cleanup():
expected_b64 = base64.b64encode(b'\xff\xd8\xff').decode('utf-8')
assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}'
assert chain[0]['path'] == '' # consumed
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
_CONTEXT,
path,
expected_owner_type='upload_image',
)
assert chain[0]['path'] == path
storage_mgr.delete_scoped_object_key.assert_not_awaited()
def test_history_retains_storage_key_without_large_base64_payload():
path = f'{_UPLOAD_PREFIX}photo.jpg'
chain = [
{
'type': 'Image',
'path': path,
'base64': 'data:image/jpeg;base64,large-payload',
}
]
history = WebSocketAdapter._history_message_chain(chain)
assert history == [{'type': 'Image', 'path': path, 'base64': ''}]
assert chain[0]['base64'] == 'data:image/jpeg;base64,large-payload'
@pytest.mark.asyncio
@@ -425,7 +425,7 @@ async def test_attachment_key_must_belong_to_connection_upload_scope():
await adapter._process_image_components(connection, message_chain)
assert message_chain[0]['base64'].startswith('data:image/png;base64,')
assert message_chain[0]['path'] == ''
assert message_chain[0]['path'] == 'v1/current/upload_image/key.png'
storage_mgr.scoped_prefix.assert_called_once_with(
connection.execution_context,
owner_type='upload_image',
@@ -439,11 +439,7 @@ async def test_attachment_key_must_belong_to_connection_upload_scope():
'v1/current/upload_image/key.png',
expected_owner_type='upload_image',
)
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
connection.execution_context,
'v1/current/upload_image/key.png',
expected_owner_type='upload_image',
)
storage_mgr.delete_scoped_object_key.assert_not_awaited()
with pytest.raises(ValueError, match='does not belong'):
await adapter._process_image_components(
@@ -20,6 +20,7 @@ from tests.factories import text_query
from langbot_plugin.entities.io.context import InstallationBinding
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
TEST_EXECUTION_CONTEXT = ExecutionContext(
@@ -76,6 +77,63 @@ def configure_handler(connector, runtime_handler):
return runtime_handler
async def _collect_agent_results(connector, context):
return [
result
async for result in connector.run_agent(
'qa',
'agent-runner',
'default',
context,
)
]
class TestRunAgent:
@pytest.mark.asyncio
async def test_revalidates_trusted_execution_context(self):
connector = create_mock_connector()
connector._current_execution_context = AsyncMock(
return_value=TEST_EXECUTION_CONTEXT
)
class RuntimeHandler:
installation_scope = Mock(
side_effect=lambda _binding: nullcontext()
)
async def run_agent(self, *_args):
yield {'type': 'run.completed'}
configure_handler(connector, RuntimeHandler())
results = await _collect_agent_results(
connector,
{'conversation': {'workspace_id': TEST_EXECUTION_CONTEXT.workspace_uuid}},
)
assert results == [{'type': 'run.completed'}]
connector.require_workspace_context.assert_awaited_once_with(
TEST_EXECUTION_CONTEXT
)
@pytest.mark.asyncio
async def test_rejects_payload_workspace_mismatch(self):
connector = create_mock_connector()
connector._current_execution_context = AsyncMock(
return_value=TEST_EXECUTION_CONTEXT
)
configure_handler(connector, AsyncMock())
with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'):
await _collect_agent_results(
connector,
{'conversation': {'workspace_id': 'workspace-other'}},
)
connector.require_workspace_context.assert_not_awaited()
class TestListPlugins:
"""Tests for list_plugins method."""
@@ -72,6 +72,16 @@ async def test_stop_transport_tolerates_handler_callback_removing_attribute():
assert not hasattr(connector, 'handler')
@pytest.mark.asyncio
async def test_stop_transport_tolerates_cancelled_controller_close():
connector = make_connector()
connector.ctrl = SimpleNamespace(close=AsyncMock(side_effect=asyncio.CancelledError))
await connector._stop_transport()
connector.ctrl.close.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_stdio_runtime_connection_does_not_capture_unconsumed_stderr(
monkeypatch: pytest.MonkeyPatch,
@@ -249,6 +249,57 @@ async def test_local_install_persists_verified_package_before_runtime_apply():
)
@pytest.mark.asyncio
async def test_local_install_cleans_untracked_legacy_plugin_before_runtime_apply():
package = b'local-lbpkg-bytes'
digest = hashlib.sha256(package).hexdigest()
execution_context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
binding = InstallationBinding(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest=digest,
)
app = SimpleNamespace(
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
deployment=SimpleNamespace(mode='oss'),
logger=Mock(),
)
connector = PluginRuntimeConnector(app, AsyncMock())
connector.handler = runtime_handler()
connector._current_execution_context = AsyncMock(return_value=execution_context)
connector._inspect_plugin_package = Mock(return_value=('author', 'plugin'))
connector._store_artifact_package = AsyncMock()
connector._persist_installation_package = AsyncMock(return_value=(binding, None, False))
connector._wait_for_installed_plugin_ready = AsyncMock()
events: list[str] = []
async def delete_legacy_plugin(plugin_author: str, plugin_name: str):
assert (plugin_author, plugin_name) == ('author', 'plugin')
events.append('cleanup')
yield {'current_action': 'plugin deleted'}
async def apply_installation(*_args, **_kwargs):
events.append('apply')
return {'state': 'starting'}
connector.handler.delete_plugin = delete_legacy_plugin
connector.handler.apply_plugin_installation = AsyncMock(side_effect=apply_installation)
await connector.install_plugin(
PluginInstallSource.LOCAL,
{'plugin_file': package},
)
assert events == ['cleanup', 'apply']
@pytest.mark.asyncio
@pytest.mark.parametrize(('remaining_references', 'statement_count'), [(1, 1), (0, 2)])
async def test_artifact_cleanup_is_reference_counted_within_workspace(
@@ -313,6 +313,21 @@ def test_runtime_connection_is_instance_scoped_and_unbound():
assert runtime_handler.bound_action_context is None
def test_explicit_installation_scope_overrides_nested_inbound_context():
runtime_handler, _app, target_binding = make_handler()
caller_context = workspace_context().for_installation('legacy-caller')
token = runtime_handler._current_action_context.set(caller_context)
try:
assert runtime_handler.resolve_outbound_action_context(None) == caller_context
with runtime_handler.installation_scope(target_binding):
assert runtime_handler.resolve_outbound_action_context(None) == target_binding
with runtime_handler.installation_scope(None):
assert runtime_handler.resolve_outbound_action_context(None) is None
assert runtime_handler.resolve_outbound_action_context(None) == caller_context
finally:
runtime_handler._current_action_context.reset(token)
def test_inbound_tenant_action_requires_complete_installation_envelope():
runtime_handler, _app, installation_context = make_handler()
@@ -363,6 +378,44 @@ async def test_legacy_oss_worker_capability_remains_usable_after_identity_migrat
assert response.code == 0
@pytest.mark.asyncio
async def test_legacy_oss_knowledge_file_reply_uses_complete_installation_binding():
runtime_handler, app, installation_context = make_handler()
app.deployment = SimpleNamespace(mode='oss')
setting = SimpleNamespace(
plugin_author='author-a',
plugin_name='plugin-a',
installation_uuid=installation_context.installation_uuid,
runtime_revision=installation_context.runtime_revision,
artifact_digest=installation_context.artifact_digest,
)
result = Mock()
result.first.return_value = setting
app.persistence_mgr.execute_async.return_value = result
app.rag_runtime_service = SimpleNamespace(
get_file_stream=AsyncMock(return_value=b'knowledge-file'),
)
runtime_handler.send_file = AsyncMock(return_value='knowledge-file-key')
legacy_context = workspace_context().for_installation(
installation_context.installation_uuid
)
response = await invoke_with_context(
runtime_handler,
legacy_context,
PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM,
{'storage_path': 'knowledge/file.txt'},
)
assert response.code == 0
assert response.data == {'file_key': 'knowledge-file-key'}
runtime_handler.send_file.assert_awaited_once_with(
b'knowledge-file',
'',
action_context=installation_context,
)
def test_installation_uuid_cannot_move_between_workspaces():
runtime_handler, _app, binding = make_handler()
moved = binding.model_copy(update={'workspace_uuid': 'workspace-b', 'runtime_revision': 2})
@@ -1004,7 +1004,9 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo
ap = _make_ap()
ap.box_service.available = True
shared_workspace = tmp_path / 'shared-box-workspace' / 'tenants' / 'workspace-a'
ap.box_service.default_workspace = str(tmp_path / 'shared-box-workspace')
ap.box_service.workspace_host_path = Mock(return_value=str(shared_workspace))
ap.box_service.create_session = AsyncMock(return_value={})
ap.box_service.build_spec = Mock(return_value='validated-spec')
ap.box_service.client = SimpleNamespace(
@@ -1053,8 +1055,9 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo
assert ap.box_service.build_spec.call_args.kwargs.get('skip_host_mount_validation', False) is False
assert ap.box_service.build_spec.call_args.args[0]['host_path'] == str(host_path)
staged_file = tmp_path / 'shared-box-workspace' / '.mcp' / 'u1' / 'workspace' / 'server.py'
staged_file = shared_workspace / '.mcp' / 'u1' / 'workspace' / 'server.py'
assert staged_file.read_text(encoding='utf-8') == 'print("hello")\n'
ap.box_service.workspace_host_path.assert_called_with(session.execution_context)
assert ap.box_service.start_managed_process.await_args.args[0] == session.execution_context
process_payload = ap.box_service.start_managed_process.await_args.args[2]
@@ -49,6 +49,68 @@ interface DebugDialogProps {
onConnectionStatusChange?: (isConnected: boolean) => void;
}
function AuthenticatedMessageImage({
image,
onOpen,
}: {
image: Image;
onOpen: (imageUrl: string) => void;
}) {
const [downloadedUrl, setDownloadedUrl] = useState('');
const directUrl =
image.url ||
(image.base64
? image.base64.startsWith('data:')
? image.base64
: `data:image/jpeg;base64,${image.base64}`
: '');
useEffect(() => {
if (directUrl || !image.path) return;
let disposed = false;
let objectUrl = '';
const encodedPath = image.path
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
void httpClient
.downloadFile(`/api/v1/files/image/${encodedPath}`)
.then((response) => {
objectUrl = URL.createObjectURL(response.data);
if (disposed) {
URL.revokeObjectURL(objectUrl);
return;
}
setDownloadedUrl(objectUrl);
})
.catch((error) => {
console.error('Failed to load Debug Chat image:', error);
});
return () => {
disposed = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [directUrl, image.path]);
const imageUrl = directUrl || downloadedUrl;
if (!imageUrl) return null;
return (
<div className="my-2">
<img
src={imageUrl}
alt="Image"
data-debug-chat-message-image="true"
className="max-w-full max-h-96 rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => onOpen(imageUrl)}
/>
</div>
);
}
export default function DebugDialog({
open,
pipelineId,
@@ -477,22 +539,15 @@ export default function DebugDialog({
case 'Image': {
const img = component as Image;
const imageUrl = img.url || (img.base64 ? img.base64 : '');
if (!imageUrl) return null;
return (
<div key={index} className="my-2">
<img
src={imageUrl}
alt="Image"
className="max-w-full max-h-96 rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
setPreviewImageUrl(imageUrl);
setShowImagePreview(true);
}}
/>
</div>
<AuthenticatedMessageImage
key={`${index}-${img.path || img.url || 'inline'}`}
image={img}
onOpen={(imageUrl) => {
setPreviewImageUrl(imageUrl);
setShowImagePreview(true);
}}
/>
);
}
@@ -907,6 +962,7 @@ export default function DebugDialog({
<img
src={image.preview}
alt={`preview-${index}`}
data-debug-chat-attachment-preview="true"
className="w-20 h-20 object-cover rounded-lg border"
/>
) : (