171 lines
5.9 KiB
JavaScript
171 lines
5.9 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawn } from "node:child_process";
|
|
import { createServer } from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
|
function listen(server, host = "127.0.0.1") {
|
|
return new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, host, () => {
|
|
server.off("error", reject);
|
|
resolve(server.address().port);
|
|
});
|
|
});
|
|
}
|
|
|
|
function readJsonBody(request) {
|
|
return new Promise((resolve, reject) => {
|
|
let raw = "";
|
|
request.setEncoding("utf8");
|
|
request.on("data", (chunk) => {
|
|
raw += chunk;
|
|
});
|
|
request.on("end", () => {
|
|
try {
|
|
resolve(raw ? JSON.parse(raw) : {});
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
request.on("error", reject);
|
|
});
|
|
}
|
|
|
|
async function waitFor(predicate, timeoutMs = 5000) {
|
|
const started = Date.now();
|
|
while (Date.now() - started < timeoutMs) {
|
|
if (await predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
throw new Error("waitFor timeout");
|
|
}
|
|
|
|
test("local-agent forwards computer-use dialog intervention to control plane instead of completing task", async () => {
|
|
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-dialog-flow-"));
|
|
const skillsDir = path.join(runtimeRoot, "skills");
|
|
await mkdir(skillsDir, { recursive: true });
|
|
|
|
const completeBodies = [];
|
|
const claimBodies = [];
|
|
let claimCount = 0;
|
|
const controlPlane = createServer(async (request, response) => {
|
|
const url = request.url || "";
|
|
if (request.method === "POST" && url === "/api/v1/master-agent/tasks/claim") {
|
|
claimCount += 1;
|
|
claimBodies.push(await readJsonBody(request));
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(
|
|
JSON.stringify({
|
|
ok: true,
|
|
task:
|
|
claimCount === 1
|
|
? {
|
|
taskId: "desktop-dialog-task",
|
|
taskType: "desktop_control",
|
|
projectId: "master-agent",
|
|
requestText: "打开系统设置并处理权限弹窗",
|
|
requestedByAccount: "krisolo",
|
|
deviceId: "mac-studio",
|
|
dispatchExecutionId: "dispatch-dialog-task",
|
|
targetThreadId: "thread-dialog",
|
|
requestedAt: "2026-05-11T10:00:00.000Z",
|
|
riskLevel: "high",
|
|
}
|
|
: null,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
request.method === "POST" &&
|
|
url === "/api/v1/master-agent/tasks/desktop-dialog-task/complete"
|
|
) {
|
|
completeBodies.push(await readJsonBody(request));
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ ok: true }));
|
|
return;
|
|
}
|
|
if (request.method === "POST" && url === "/api/device-heartbeat") {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ ok: true, token: "server-token" }));
|
|
return;
|
|
}
|
|
if (request.method === "POST" && url === "/api/v1/app-logs") {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ ok: true }));
|
|
return;
|
|
}
|
|
if (request.method === "POST" && url === "/api/v1/devices/mac-studio/skills") {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ ok: true }));
|
|
return;
|
|
}
|
|
response.writeHead(404, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ ok: false, message: "not_found", url }));
|
|
});
|
|
|
|
const controlPort = await listen(controlPlane);
|
|
const agentServer = createServer();
|
|
const agentPort = await listen(agentServer);
|
|
await new Promise((resolve) => agentServer.close(resolve));
|
|
|
|
const configPath = path.join(runtimeRoot, "local-agent-config.json");
|
|
await writeFile(
|
|
configPath,
|
|
JSON.stringify({
|
|
port: agentPort,
|
|
bindHost: "127.0.0.1",
|
|
controlPlaneUrl: `http://127.0.0.1:${controlPort}`,
|
|
deviceId: "mac-studio",
|
|
token: "local-token",
|
|
name: "Mac Studio",
|
|
account: "krisolo",
|
|
status: "online",
|
|
codexSessionDiscoveryEnabled: false,
|
|
skillsDir,
|
|
masterAgentEnabled: true,
|
|
masterAgentPollIntervalMs: 60_000,
|
|
heartbeatIntervalMs: 60_000,
|
|
skillLifecycleEnabled: false,
|
|
computerUseEnabled: true,
|
|
computerUseCommand: process.execPath,
|
|
computerUseArgs: ["tests/fixtures/computer-use-dialog-runtime.mjs"],
|
|
computerUseWorkdir: repoRoot,
|
|
computerUseTimeoutMs: 5000,
|
|
}),
|
|
);
|
|
|
|
const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], {
|
|
cwd: repoRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
try {
|
|
await waitFor(() => completeBodies.length > 0);
|
|
assert.equal(claimBodies.at(0)?.waitMs, 25_000);
|
|
const body = completeBodies.at(0);
|
|
assert.equal(body.status, "needs_user_action");
|
|
assert.equal(body.kind, "dialog_intervention_required");
|
|
assert.equal(body.dialogId, "dialog-system-permission");
|
|
assert.equal(body.appName, "System Settings");
|
|
assert.equal(body.platform, "darwin");
|
|
assert.equal(body.risk, "high");
|
|
assert.equal(body.summary, "System Settings 弹窗需要用户确认。");
|
|
assert.deepEqual(body.availableActions, ["handled_on_device", "cancel_task"]);
|
|
assert.equal(body.dispatchExecutionId, "dispatch-dialog-task");
|
|
assert.equal(body.targetThreadId, "thread-dialog");
|
|
assert.equal(body.replyBody, undefined);
|
|
} finally {
|
|
child.kill("SIGTERM");
|
|
controlPlane.close();
|
|
await rm(runtimeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|