Files
boss/tests/local-agent-heartbeat-capabilities.test.mjs
2026-06-04 14:09:46 +08:00

372 lines
12 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
async function startMockControlPlane() {
let resolveHeartbeat;
const heartbeatReceived = new Promise((resolve) => {
resolveHeartbeat = resolve;
});
const server = createServer(async (request, response) => {
const chunks = [];
for await (const chunk of request) {
chunks.push(chunk);
}
const bodyText = Buffer.concat(chunks).toString("utf8");
if (request.method === "POST" && request.url === "/api/device-heartbeat") {
resolveHeartbeat({
headers: request.headers,
bodyText,
});
}
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true }));
});
await new Promise((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("failed to bind mock control plane");
}
return {
server,
port: address.port,
heartbeatReceived,
};
}
test("local-agent heartbeat reports gui and cli capability state", async () => {
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-heartbeat-capabilities-"));
const skillsDir = path.join(runtimeRoot, "skills");
await mkdir(skillsDir, { recursive: true });
const mockControlPlane = await startMockControlPlane();
const exampleConfig = JSON.parse(
await readFile(path.join(repoRoot, "local-agent", "config.example.json"), "utf8"),
);
const configPath = path.join(runtimeRoot, "config.json");
await writeFile(
configPath,
JSON.stringify(
{
...exampleConfig,
bindHost: "127.0.0.1",
port: 0,
controlPlaneUrl: `http://127.0.0.1:${mockControlPlane.port}`,
heartbeatIntervalMs: 60_000,
masterAgentPollIntervalMs: 60_000,
masterAgentEnabled: false,
codexSessionDiscoveryEnabled: false,
codexAppServerCommand: process.execPath,
projects: [],
projectCandidates: [],
skillsDir,
},
null,
2,
),
"utf8",
);
const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
try {
const heartbeatRequest = await Promise.race([
mockControlPlane.heartbeatReceived,
new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`timed out waiting for heartbeat\n${stderr}`));
}, 8000);
}),
]);
const payload = JSON.parse(heartbeatRequest.bodyText);
assert.ok(payload.capabilities, "heartbeat payload should include device capabilities");
assert.equal(payload.capabilities.gui.connected, false);
assert.equal(payload.capabilities.cli.connected, true);
assert.equal(payload.capabilities.codexAppServer.connected, true);
assert.equal(payload.preferredExecutionMode, "cli");
} finally {
child.kill("SIGTERM");
await new Promise((resolve) => {
child.once("close", resolve);
}).catch(() => null);
await new Promise((resolve) => {
mockControlPlane.server.close(resolve);
});
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test("local-agent heartbeat reports Codex App Server capability only when enabled and executable", async () => {
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-app-server-capability-"));
const skillsDir = path.join(runtimeRoot, "skills");
await mkdir(skillsDir, { recursive: true });
const mockControlPlane = await startMockControlPlane();
const exampleConfig = JSON.parse(
await readFile(path.join(repoRoot, "local-agent", "config.example.json"), "utf8"),
);
const configPath = path.join(runtimeRoot, "config.json");
await writeFile(
configPath,
JSON.stringify(
{
...exampleConfig,
bindHost: "127.0.0.1",
port: 0,
controlPlaneUrl: `http://127.0.0.1:${mockControlPlane.port}`,
heartbeatIntervalMs: 60_000,
masterAgentPollIntervalMs: 60_000,
masterAgentEnabled: false,
codexSessionDiscoveryEnabled: false,
codexAppServerEnabled: true,
codexAppServerCommand: process.execPath,
projects: [],
projectCandidates: [],
skillsDir,
},
null,
2,
),
"utf8",
);
const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
try {
const heartbeatRequest = await Promise.race([
mockControlPlane.heartbeatReceived,
new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`timed out waiting for heartbeat\n${stderr}`));
}, 8000);
}),
]);
const payload = JSON.parse(heartbeatRequest.bodyText);
assert.equal(payload.capabilities.codexAppServer.connected, true);
} finally {
child.kill("SIGTERM");
await new Promise((resolve) => {
child.once("close", resolve);
}).catch(() => null);
await new Promise((resolve) => {
mockControlPlane.server.close(resolve);
});
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test("local-agent heartbeat reports Codex App Server discovered models, skills, plugins, and apps", async () => {
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-app-server-discovery-"));
const skillsDir = path.join(runtimeRoot, "skills");
await mkdir(skillsDir, { recursive: true });
const mockControlPlane = await startMockControlPlane();
const exampleConfig = JSON.parse(
await readFile(path.join(repoRoot, "local-agent", "config.example.json"), "utf8"),
);
const configPath = path.join(runtimeRoot, "config.json");
await writeFile(
configPath,
JSON.stringify(
{
...exampleConfig,
bindHost: "127.0.0.1",
port: 0,
controlPlaneUrl: `http://127.0.0.1:${mockControlPlane.port}`,
heartbeatIntervalMs: 60_000,
masterAgentPollIntervalMs: 60_000,
masterAgentEnabled: false,
codexSessionDiscoveryEnabled: false,
codexAppServerEnabled: true,
codexAppServerCommand: process.execPath,
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
codexAppServerWorkdir: repoRoot,
codexAppServerTimeoutMs: 5000,
codexAppServerDiscoveryTtlMs: 60_000,
projects: [],
projectCandidates: [],
skillsDir,
},
null,
2,
),
"utf8",
);
const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
try {
const heartbeatRequest = await Promise.race([
mockControlPlane.heartbeatReceived,
new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`timed out waiting for heartbeat\n${stderr}`));
}, 8000);
}),
]);
const payload = JSON.parse(heartbeatRequest.bodyText);
const metadata = payload.capabilities.codexAppServer.metadata;
assert.equal(payload.capabilities.codexAppServer.connected, true);
assert.equal(metadata.models[0].id, "gpt-5.4");
assert.equal(metadata.models[0].displayName, "GPT-5.4");
assert.equal(metadata.defaultModelId, "gpt-5.4");
assert.equal(metadata.fastModelId, "gpt-5.4-mini");
assert.equal(metadata.providerCapabilities.webSearch, true);
assert.equal(metadata.skills[0].name, "image2-ui-prototype");
assert.equal(metadata.plugins[0].id, "github");
assert.equal(metadata.apps[0].id, "canva");
} finally {
child.kill("SIGTERM");
await new Promise((resolve) => {
child.once("close", resolve);
}).catch(() => null);
await new Promise((resolve) => {
mockControlPlane.server.close(resolve);
});
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test("local-agent heartbeat enriches Codex App Server threads with recent final replies", async () => {
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-app-server-recent-replies-"));
const skillsDir = path.join(runtimeRoot, "skills");
await mkdir(skillsDir, { recursive: true });
const mockControlPlane = await startMockControlPlane();
const exampleConfig = JSON.parse(
await readFile(path.join(repoRoot, "local-agent", "config.example.json"), "utf8"),
);
const configPath = path.join(runtimeRoot, "config.json");
await writeFile(
configPath,
JSON.stringify(
{
...exampleConfig,
bindHost: "127.0.0.1",
port: 0,
controlPlaneUrl: `http://127.0.0.1:${mockControlPlane.port}`,
heartbeatIntervalMs: 60_000,
masterAgentPollIntervalMs: 60_000,
masterAgentEnabled: false,
codexSessionDiscoveryEnabled: false,
codexAppServerEnabled: true,
codexAppServerCommand: process.execPath,
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
codexAppServerWorkdir: repoRoot,
codexAppServerTimeoutMs: 5000,
codexAppServerDiscoveryTtlMs: 60_000,
projects: ["boss"],
projectCandidates: [
{
folderName: "boss",
folderRef: repoRoot,
threadId: "thr-active",
threadDisplayName: "Boss App Server rollout",
codexFolderRef: repoRoot,
codexThreadRef: "thr-active",
lastActiveAt: "2026-06-03T08:20:00.000Z",
suggestedImport: true,
},
],
skillsDir,
},
null,
2,
),
"utf8",
);
const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
try {
const heartbeatRequest = await Promise.race([
mockControlPlane.heartbeatReceived,
new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`timed out waiting for heartbeat\n${stderr}`));
}, 8000);
}),
]);
const payload = JSON.parse(heartbeatRequest.bodyText);
const serializedPayload = JSON.stringify(payload);
const candidate = payload.projectCandidates.find((item) => item.codexThreadRef === "thr-active");
assert.ok(candidate, "expected heartbeat to keep the imported Codex App Server thread candidate");
assert.equal(candidate.folderName, "boss");
assert.deepEqual(candidate.recentAssistantMessages, [
{
messageId: "codex-app-server:thr-active:turn-active-1:agent-final-app-server-reply",
body: "App Server 最终回复已完成同步。",
sentAt: "2026-06-03T08:10:00.000Z",
phase: "final_answer",
},
]);
assert.ok(!serializedPayload.includes("private active turn text should not leak"));
assert.ok(!serializedPayload.includes("private item content should not leak"));
assert.ok(!serializedPayload.includes("private user summary text should not leak"));
} finally {
child.kill("SIGTERM");
await new Promise((resolve) => {
child.once("close", resolve);
}).catch(() => null);
await new Promise((resolve) => {
mockControlPlane.server.close(resolve);
});
await rm(runtimeRoot, { recursive: true, force: true });
}
});