feat: add claw backend adapter

This commit is contained in:
kris
2026-04-03 01:36:29 +08:00
parent 8daaea01fd
commit 39b576cc42
23 changed files with 1212 additions and 23 deletions

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getClawBackendConfigForTesting,
isClawBackendConfiguredForTesting,
} from "../src/lib/execution/backends/claw-config.ts";
function snapshotEnv() {
return {
BOSS_CLAW_ENABLED: process.env.BOSS_CLAW_ENABLED,
BOSS_CLAW_COMMAND: process.env.BOSS_CLAW_COMMAND,
BOSS_CLAW_ARGS: process.env.BOSS_CLAW_ARGS,
BOSS_CLAW_WORKDIR: process.env.BOSS_CLAW_WORKDIR,
BOSS_CLAW_TIMEOUT_MS: process.env.BOSS_CLAW_TIMEOUT_MS,
BOSS_CLAW_DEFAULT_MODEL: process.env.BOSS_CLAW_DEFAULT_MODEL,
};
}
function restoreEnv(snapshot: ReturnType<typeof snapshotEnv>) {
process.env.BOSS_CLAW_ENABLED = snapshot.BOSS_CLAW_ENABLED;
process.env.BOSS_CLAW_COMMAND = snapshot.BOSS_CLAW_COMMAND;
process.env.BOSS_CLAW_ARGS = snapshot.BOSS_CLAW_ARGS;
process.env.BOSS_CLAW_WORKDIR = snapshot.BOSS_CLAW_WORKDIR;
process.env.BOSS_CLAW_TIMEOUT_MS = snapshot.BOSS_CLAW_TIMEOUT_MS;
process.env.BOSS_CLAW_DEFAULT_MODEL = snapshot.BOSS_CLAW_DEFAULT_MODEL;
}
test("Claw backend 在未配置时默认关闭", () => {
const previous = snapshotEnv();
delete process.env.BOSS_CLAW_ENABLED;
delete process.env.BOSS_CLAW_COMMAND;
delete process.env.BOSS_CLAW_ARGS;
delete process.env.BOSS_CLAW_WORKDIR;
delete process.env.BOSS_CLAW_TIMEOUT_MS;
delete process.env.BOSS_CLAW_DEFAULT_MODEL;
const config = getClawBackendConfigForTesting();
assert.equal(config.enabled, false);
assert.equal(isClawBackendConfiguredForTesting(config), false);
restoreEnv(previous);
});
test("Claw backend 在配置完整时返回 command、args 和 timeout", () => {
const previous = snapshotEnv();
process.env.BOSS_CLAW_ENABLED = "true";
process.env.BOSS_CLAW_COMMAND = "claw";
process.env.BOSS_CLAW_ARGS = "run --json";
process.env.BOSS_CLAW_WORKDIR = "/tmp/claw";
process.env.BOSS_CLAW_TIMEOUT_MS = "45000";
const config = getClawBackendConfigForTesting();
assert.equal(config.enabled, true);
assert.equal(config.command, "claw");
assert.deepEqual(config.args, ["run", "--json"]);
assert.equal(config.cwd, "/tmp/claw");
assert.equal(config.timeoutMs, 45000);
assert.equal(isClawBackendConfiguredForTesting(config), true);
restoreEnv(previous);
});

121
tests/claw-backend.test.ts Normal file
View File

@@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createClawBackendForTesting } from "../src/lib/execution/backends/claw-backend.ts";
test("Claw backend 只在启用且请求类型受支持时 canHandle", async () => {
const backend = createClawBackendForTesting({
config: {
enabled: true,
command: "claw",
args: ["run"],
timeoutMs: 45_000,
},
runner: async () => ({
status: "completed",
backendId: "claw-runtime",
output: "ok",
}),
});
assert.equal(
await backend.canHandle({
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: "msg-1",
body: "继续",
}),
true,
);
assert.equal(
await backend.canHandle({
kind: "dispatch_execution",
projectId: "project-1",
requestMessageId: "msg-2",
body: "继续",
}),
false,
);
});
test("Claw backend 执行时会把 executionPrompt、模型和推理强度交给 runner", async () => {
const calls: unknown[] = [];
const backend = createClawBackendForTesting({
config: {
enabled: true,
command: "claw",
args: ["run"],
timeoutMs: 45_000,
defaultModel: "claude-sonnet",
},
runner: async (input) => {
calls.push(input);
return {
status: "completed",
backendId: "claw-runtime",
output: "链路正常",
};
},
});
const result = await backend.execute({
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: "msg-1",
body: "继续推进",
executionPrompt: "系统提示词 + 用户提示词 + 当前消息",
modelOverride: "gpt-5.4",
reasoningEffortOverride: "high",
});
assert.equal(result.status, "completed");
assert.deepEqual(calls, [
{
config: {
enabled: true,
command: "claw",
args: ["run"],
timeoutMs: 45_000,
defaultModel: "claude-sonnet",
},
payload: {
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: "msg-1",
body: "继续推进",
executionPrompt: "系统提示词 + 用户提示词 + 当前消息",
model: "gpt-5.4",
reasoningEffort: "high",
},
},
]);
});
test("Claw backend describe 返回稳定描述", async () => {
const backend = createClawBackendForTesting({
config: {
enabled: true,
command: "claw",
args: ["run"],
timeoutMs: 45_000,
},
runner: async () => ({
status: "completed",
backendId: "claw-runtime",
output: "ok",
}),
});
const description = await backend.describe({
kind: "thread_reply",
projectId: "project-1",
requestMessageId: "msg-1",
body: "继续",
});
assert.deepEqual(description, {
backendId: "claw-runtime",
label: "Claw Runtime",
mode: "local",
});
});

180
tests/claw-runner.test.ts Normal file
View File

@@ -0,0 +1,180 @@
import assert from "node:assert/strict";
import { mkdtemp, realpath, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { runClawCommandForTesting } from "../src/lib/execution/backends/claw-runner.ts";
async function createTempScript(source: string) {
const dir = await mkdtemp(join(tmpdir(), "claw-runner-"));
const scriptPath = join(dir, "claw-script.mjs");
await writeFile(scriptPath, source, "utf8");
return { dir, scriptPath };
}
test("Claw runner 把 completed JSON 响应映射成 completed并把 payload 写入 stdin", async () => {
const workspace = await mkdtemp(join(tmpdir(), "claw-runner-cwd-"));
const expectedWorkspace = await realpath(workspace);
const { scriptPath } = await createTempScript(`
import { readFile } from "node:fs/promises";
let stdin = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
stdin += chunk;
}
const payload = JSON.parse(stdin);
process.stdout.write(JSON.stringify({
status: "completed",
output: JSON.stringify({
body: payload.body,
cwd: process.cwd(),
command: payload.command,
}),
}));
`);
const result = await runClawCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
cwd: workspace,
timeoutMs: 1000,
},
payload: {
body: "继续执行",
command: "claw",
},
});
assert.equal(result.status, "completed");
if (result.status !== "completed") {
assert.fail("expected completed");
}
const output = JSON.parse(result.output) as {
body: string;
cwd: string;
command: string;
};
assert.equal(output.body, "继续执行");
assert.equal(output.cwd, expectedWorkspace);
assert.equal(output.command, "claw");
});
test("Claw runner 把 failed JSON 响应映射成 failed", async () => {
const { scriptPath } = await createTempScript(`
let stdin = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
stdin += chunk;
}
const payload = JSON.parse(stdin);
process.stdout.write(JSON.stringify({
status: "failed",
error: "bad-request:" + payload.body,
}));
`);
const result = await runClawCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 1000,
},
payload: {
body: "格式不对",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.equal(result.error, "bad-request:格式不对");
});
test("Claw runner 把无效 JSON 响应映射成 INVALID_CLAW_RESPONSE", async () => {
const { scriptPath } = await createTempScript(`
process.stdout.write("not-json");
`);
const result = await runClawCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 1000,
},
payload: {
body: "anything",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.match(result.error, /INVALID_CLAW_RESPONSE/);
});
test("Claw runner 把非零退出码映射成 stderr 或退出码错误", async () => {
const { scriptPath } = await createTempScript(`
process.stderr.write("claw crashed");
process.exit(2);
`);
const result = await runClawCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 1000,
},
payload: {
body: "anything",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.match(result.error, /claw crashed/);
});
test("Claw runner 超时后返回 CLAW_TIMEOUT", async () => {
const { scriptPath } = await createTempScript(`
setTimeout(() => {
process.stdout.write(JSON.stringify({ status: "completed", output: "late" }));
}, 500);
`);
const result = await runClawCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 50,
},
payload: {
body: "slow",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.match(result.error, /CLAW_TIMEOUT/);
});

View File

@@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { selectExecutionBackendForTesting } from "@/lib/execution/backend-selector";
import {
listExecutionBackendChoices,
selectExecutionBackendForTesting,
} from "@/lib/execution/backend-selector";
test("selectExecutionBackendForTesting prefers the ready primary master codex node", async () => {
const backend = await selectExecutionBackendForTesting({
@@ -73,3 +76,46 @@ test("selectExecutionBackendForTesting falls back to master node last when highe
assert.equal(backend.backendId, "master-codex-node");
});
test("listExecutionBackendChoices keeps claw disabled by default", () => {
const backends = listExecutionBackendChoices({
primary: { provider: "master_codex_node", status: "ready" },
backups: [{ provider: "openai_api", status: "ready" }],
requestKind: "master_agent_reply",
});
assert.deepEqual(
backends.map((backend) => backend.backendId),
["master-codex-node", "openai-api"],
);
});
test("selectExecutionBackendForTesting honors an explicit claw request when claw is enabled", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "ready" },
backups: [{ provider: "openai_api", status: "ready" }],
requestKind: "master_agent_reply",
requestedBackendId: "claw-runtime",
claw: {
enabled: true,
supportsKinds: ["master_agent_reply", "thread_reply"],
},
});
assert.equal(backend.backendId, "claw-runtime");
});
test("selectExecutionBackendForTesting falls back when claw is requested but unavailable", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "ready" },
backups: [{ provider: "openai_api", status: "ready" }],
requestKind: "master_agent_reply",
requestedBackendId: "claw-runtime",
claw: {
enabled: false,
supportsKinds: ["master_agent_reply"],
},
});
assert.equal(backend.backendId, "master-codex-node");
});

View File

@@ -19,10 +19,13 @@ test("ExecutionRequest 工厂会生成稳定默认字段", () => {
assert.equal(request.projectId, "master-agent");
assert.equal(request.requestMessageId, "msg-1");
assert.equal(request.body, "你好");
assert.equal(request.executionPrompt, undefined);
assert.equal(request.targetProjectId, undefined);
assert.equal(request.targetThreadId, undefined);
assert.equal(Object.prototype.hasOwnProperty.call(request, "requestedByAccount"), true);
assert.equal(Object.prototype.hasOwnProperty.call(request, "requestedByLabel"), true);
assert.equal(Object.prototype.hasOwnProperty.call(request, "executionPrompt"), true);
assert.equal(Object.prototype.hasOwnProperty.call(request, "requestedBackendId"), true);
assert.equal(Object.prototype.hasOwnProperty.call(request, "taskId"), true);
assert.equal(Object.prototype.hasOwnProperty.call(request, "targetProjectId"), true);
assert.equal(Object.prototype.hasOwnProperty.call(request, "targetThreadId"), true);

View File

@@ -53,6 +53,9 @@ async function resetMasterAgentControls() {
const project = state.projects.find((item) => item.id === "master-agent");
assert.ok(project, "expected seeded master-agent project");
delete project.agentControls;
state.userProjectAgentControls = state.userProjectAgentControls.filter(
(item) => item.projectId !== "master-agent",
);
await writeState(state);
}
@@ -110,6 +113,7 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
body: JSON.stringify({
modelOverride: "gpt-5.4",
reasoningEffortOverride: "medium",
backendOverride: "claw-runtime",
}),
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
@@ -121,12 +125,14 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
controls: {
modelOverride?: string;
reasoningEffortOverride?: string;
backendOverride?: string;
updatedAt: string;
} | null;
};
assert.equal(postPayload.ok, true);
assert.equal(postPayload.controls?.modelOverride, "gpt-5.4");
assert.equal(postPayload.controls?.reasoningEffortOverride, "medium");
assert.equal(postPayload.controls?.backendOverride, "claw-runtime");
const getResponse = await getAgentControlsRoute(
new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", {
@@ -142,12 +148,14 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
controls: {
modelOverride?: string;
reasoningEffortOverride?: string;
backendOverride?: string;
updatedAt: string;
} | null;
};
assert.equal(getPayload.ok, true);
assert.equal(getPayload.controls?.modelOverride, "gpt-5.4");
assert.equal(getPayload.controls?.reasoningEffortOverride, "medium");
assert.equal(getPayload.controls?.backendOverride, "claw-runtime");
const projectResponse = await getProjectRoute(
new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent", {
@@ -163,12 +171,14 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
agentControls: {
modelOverride?: string;
reasoningEffortOverride?: string;
backendOverride?: string;
updatedAt: string;
} | null;
};
assert.equal(projectPayload.ok, true);
assert.equal(projectPayload.agentControls?.modelOverride, "gpt-5.4");
assert.equal(projectPayload.agentControls?.reasoningEffortOverride, "medium");
assert.equal(projectPayload.agentControls?.backendOverride, "claw-runtime");
});
test("master-agent 对话控制按当前账号隔离,不会串到其他用户", async () => {
@@ -873,6 +883,36 @@ test("POST /agent-controls rejects unknown-key payload and preserves controls",
assert.equal(afterProject?.updatedAt, beforeUpdatedAt);
});
test("master-agent 对话控制 POST 会稳定拒绝非法 backendOverride", async () => {
await setup();
const session = await createAuthSession({
account: "17600003315",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
const response = await postAgentControlsRoute(
new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", {
method: "POST",
headers: {
"content-type": "application/json",
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
},
body: JSON.stringify({
backendOverride: "bad-backend",
}),
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 400);
const payload = (await response.json()) as { ok: boolean; message?: string };
assert.equal(payload.ok, false);
assert.equal(payload.message, "INVALID_BACKEND_OVERRIDE");
});
test("master-agent controls helper 不会写入普通项目", async () => {
await setup();

View File

@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { NextRequest } from "next/server";
let runtimeRoot = "";
@@ -240,6 +240,99 @@ test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队
}
});
test("master-agent enqueue 在显式选择 claw-runtime 时会通过 Claw 异步回写回复", async () => {
const clawDir = await mkdtemp(path.join(os.tmpdir(), "boss-claw-queue-"));
const clawScriptPath = path.join(clawDir, "claw-runtime.mjs");
await writeFile(
clawScriptPath,
`
let stdin = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
stdin += chunk;
}
const payload = JSON.parse(stdin);
process.stdout.write(JSON.stringify({
status: "completed",
output: "Claw 已接管当前主 Agent 会话:" + payload.body
}));
`,
"utf8",
);
const previousEnv = {
BOSS_CLAW_ENABLED: process.env.BOSS_CLAW_ENABLED,
BOSS_CLAW_COMMAND: process.env.BOSS_CLAW_COMMAND,
BOSS_CLAW_ARGS: process.env.BOSS_CLAW_ARGS,
BOSS_CLAW_TIMEOUT_MS: process.env.BOSS_CLAW_TIMEOUT_MS,
};
process.env.BOSS_CLAW_ENABLED = "true";
process.env.BOSS_CLAW_COMMAND = process.execPath;
process.env.BOSS_CLAW_ARGS = clawScriptPath;
process.env.BOSS_CLAW_TIMEOUT_MS = "1000";
await saveAiAccount({
accountId: "master-codex-primary-claw",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "Mac 上的 Master Codex Node",
nodeId: "local-codex-node",
nodeLabel: "本机 Codex",
model: "gpt-5.4",
enabled: true,
setActive: true,
loginStatusNote: "用于 Claw backend 队列测试。",
});
await updateProjectAgentControls("master-agent", {
backendOverride: "claw-runtime",
});
try {
const response = await POST(
await createAuthedRequest("master-agent", {
body: "请走 Claw runtime",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string; status: string } | null;
masterReply?: { accountId?: string } | null;
masterReplyState?: string | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.masterReply?.accountId, "claw-runtime");
assert.equal(payload.masterReplyState, "queued");
assert.ok(payload.task?.taskId);
await waitFor(async () => {
const state = await readState();
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
return task?.status === "completed";
});
const nextState = await readState();
const task = nextState.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
assert.equal(task?.status, "completed");
assert.equal(task?.replyBody, "Claw 已接管当前主 Agent 会话:请走 Claw runtime");
const masterProject = nextState.projects.find((project) => project.id === "master-agent");
const mirroredReply = masterProject?.messages.at(-1);
assert.match(mirroredReply?.body ?? "", /Claw 已接管当前主 Agent 会话/);
} finally {
process.env.BOSS_CLAW_ENABLED = previousEnv.BOSS_CLAW_ENABLED;
process.env.BOSS_CLAW_COMMAND = previousEnv.BOSS_CLAW_COMMAND;
process.env.BOSS_CLAW_ARGS = previousEnv.BOSS_CLAW_ARGS;
process.env.BOSS_CLAW_TIMEOUT_MS = previousEnv.BOSS_CLAW_TIMEOUT_MS;
await rm(clawDir, { recursive: true, force: true });
}
});
test("master-agent enqueue 在首选主节点离线时会回退到可用的备用主节点并返回实际账号", async () => {
await saveAiAccount({
accountId: "master-codex-primary-offline",

View File

@@ -198,6 +198,7 @@ test("prompt-profile 写入当前对话提示词时按当前账号隔离", async
headers: memberRequest.headers,
body: JSON.stringify({
promptOverride: "成员自己的当前对话提示词",
backendOverride: "claw-runtime",
}),
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
@@ -208,8 +209,12 @@ test("prompt-profile 写入当前对话提示词时按当前账号隔离", async
ok: boolean;
projectPromptOverride: string | null;
account: string;
projectControls: {
backendOverride?: string | null;
} | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.account, "18800001111");
assert.equal(payload.projectPromptOverride, "成员自己的当前对话提示词");
assert.equal(payload.projectControls?.backendOverride, "claw-runtime");
});