810 lines
28 KiB
TypeScript
810 lines
28 KiB
TypeScript
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, writeFile } from "node:fs/promises";
|
||
import { NextRequest } from "next/server";
|
||
|
||
let runtimeRoot = "";
|
||
let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
|
||
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
|
||
let getProjectAgentControls: (typeof import("../src/lib/boss-data"))["getProjectAgentControls"];
|
||
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
|
||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||
let AUTH_SESSION_COOKIE = "";
|
||
|
||
async function setup() {
|
||
if (runtimeRoot) {
|
||
return;
|
||
}
|
||
|
||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-message-queue-"));
|
||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||
|
||
const [messageRoute, data, auth] = await Promise.all([
|
||
import("../src/app/api/v1/projects/[projectId]/messages/route.ts"),
|
||
import("../src/lib/boss-data.ts"),
|
||
import("../src/lib/boss-auth.ts"),
|
||
]);
|
||
|
||
POST = messageRoute.POST;
|
||
saveAiAccount = data.saveAiAccount;
|
||
getProjectAgentControls = data.getProjectAgentControls;
|
||
updateProjectAgentControls = data.updateProjectAgentControls;
|
||
readState = data.readState;
|
||
createAuthSession = data.createAuthSession;
|
||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
||
}
|
||
|
||
async function createAuthedRequest(projectId: string, body: unknown) {
|
||
const session = await createAuthSession({
|
||
account: "17600003315",
|
||
role: "highest_admin",
|
||
displayName: "Boss 超级管理员",
|
||
loginMethod: "password",
|
||
});
|
||
|
||
return new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}/messages`, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
async function waitFor(predicate: () => Promise<boolean>, timeoutMs = 5_000) {
|
||
const startedAt = Date.now();
|
||
while (Date.now() - startedAt < timeoutMs) {
|
||
if (await predicate()) {
|
||
return;
|
||
}
|
||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||
}
|
||
throw new Error("waitFor timed out");
|
||
}
|
||
|
||
test.after(async () => {
|
||
if (runtimeRoot) {
|
||
await rm(runtimeRoot, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test.beforeEach(async () => {
|
||
await setup();
|
||
await rm(runtimeRoot, { recursive: true, force: true });
|
||
await mkdir(runtimeRoot, { recursive: true });
|
||
});
|
||
|
||
test("master-agent 明确查询可用模型时直接本地返回模型清单而不进入异步队列", async () => {
|
||
await saveAiAccount({
|
||
accountId: "openai-model-list",
|
||
label: "OpenAI 主账号",
|
||
role: "primary",
|
||
provider: "openai_api",
|
||
displayName: "OpenAI 主账号",
|
||
model: "gpt-5.4",
|
||
apiKey: "sk-openai-model-list",
|
||
enabled: true,
|
||
setActive: true,
|
||
loginStatusNote: "用于模型清单测试。",
|
||
});
|
||
|
||
await saveAiAccount({
|
||
accountId: "qwen-model-list",
|
||
label: "Qwen 备用",
|
||
role: "backup",
|
||
provider: "aliyun_qwen_api",
|
||
displayName: "阿里百炼",
|
||
model: "qwen3.5-plus",
|
||
apiKey: "sk-qwen-model-list",
|
||
enabled: true,
|
||
setActive: false,
|
||
loginStatusNote: "用于模型清单测试。",
|
||
});
|
||
|
||
const response = await POST(
|
||
await createAuthedRequest("master-agent", {
|
||
body: "主 Agent,现在有哪些模型可以用?",
|
||
}),
|
||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||
);
|
||
|
||
assert.equal(response.status, 200);
|
||
const payload = (await response.json()) as {
|
||
ok: boolean;
|
||
task?: { taskId: string } | null;
|
||
masterReplyState?: "queued" | "running" | "completed" | null;
|
||
};
|
||
assert.equal(payload.ok, true);
|
||
assert.equal(payload.task ?? null, null);
|
||
assert.equal(payload.masterReplyState, "completed");
|
||
|
||
const state = await readState();
|
||
const masterProject = state.projects.find((project) => project.id === "master-agent");
|
||
const reply = masterProject?.messages.at(-1);
|
||
assert.ok(reply, "expected the master-agent model list reply to be persisted");
|
||
assert.match(reply?.body ?? "", /当前可用模型/);
|
||
assert.match(reply?.body ?? "", /gpt-5\.4/);
|
||
assert.match(reply?.body ?? "", /qwen3\.5-plus/);
|
||
});
|
||
|
||
test("master-agent 明确要求切快模型时直接更新 controls 并返回完成态", async () => {
|
||
await saveAiAccount({
|
||
accountId: "openai-fast-switch",
|
||
label: "OpenAI 快模型",
|
||
role: "primary",
|
||
provider: "openai_api",
|
||
displayName: "OpenAI 快模型",
|
||
model: "gpt-5.4-mini",
|
||
apiKey: "sk-openai-fast-switch",
|
||
enabled: true,
|
||
setActive: true,
|
||
loginStatusNote: "用于快模型切换测试。",
|
||
});
|
||
|
||
const response = await POST(
|
||
await createAuthedRequest("master-agent", {
|
||
body: "帮我把快模型切到 gpt-5.4-mini",
|
||
}),
|
||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||
);
|
||
|
||
assert.equal(response.status, 200);
|
||
const payload = (await response.json()) as {
|
||
ok: boolean;
|
||
task?: { taskId: string } | null;
|
||
masterReplyState?: "queued" | "running" | "completed" | null;
|
||
};
|
||
assert.equal(payload.ok, true);
|
||
assert.equal(payload.task ?? null, null);
|
||
assert.equal(payload.masterReplyState, "completed");
|
||
|
||
const controls = await getProjectAgentControls("master-agent", "17600003315");
|
||
assert.equal(controls?.fastModelOverride ?? null, "gpt-5.4-mini");
|
||
|
||
const state = await readState();
|
||
assert.equal(state.masterAgentTasks.length, 0);
|
||
const masterProject = state.projects.find((project) => project.id === "master-agent");
|
||
const reply = masterProject?.messages.at(-1);
|
||
assert.ok(reply, "expected the master-agent model switch reply to be persisted");
|
||
assert.match(reply?.body ?? "", /快模型/);
|
||
assert.match(reply?.body ?? "", /gpt-5\.4-mini/);
|
||
assert.equal(reply?.senderLabel ?? "", "主Agent·gpt-5.4-mini");
|
||
});
|
||
|
||
test("master-agent 识别自然写法的模型名并切当前主模型", async () => {
|
||
await saveAiAccount({
|
||
accountId: "openai-main-switch",
|
||
label: "OpenAI 主模型",
|
||
role: "primary",
|
||
provider: "openai_api",
|
||
displayName: "OpenAI 主模型",
|
||
model: "gpt-5.4",
|
||
apiKey: "sk-openai-main-switch",
|
||
enabled: true,
|
||
setActive: true,
|
||
loginStatusNote: "用于主模型自然写法切换测试。",
|
||
});
|
||
|
||
const response = await POST(
|
||
await createAuthedRequest("master-agent", {
|
||
body: "把主agent模型换成gpt5.4",
|
||
}),
|
||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||
);
|
||
|
||
assert.equal(response.status, 200);
|
||
const payload = (await response.json()) as {
|
||
ok: boolean;
|
||
task?: { taskId: string } | null;
|
||
masterReplyState?: "queued" | "running" | "completed" | null;
|
||
};
|
||
assert.equal(payload.ok, true);
|
||
assert.equal(payload.task ?? null, null);
|
||
assert.equal(payload.masterReplyState, "completed");
|
||
|
||
const controls = await getProjectAgentControls("master-agent", "17600003315");
|
||
assert.equal(controls?.modelOverride ?? null, "gpt-5.4");
|
||
|
||
const state = await readState();
|
||
const masterProject = state.projects.find((project) => project.id === "master-agent");
|
||
const reply = masterProject?.messages.at(-1);
|
||
assert.ok(reply, "expected the master-agent natural model switch reply to be persisted");
|
||
assert.match(reply?.body ?? "", /当前主模型/);
|
||
assert.match(reply?.body ?? "", /gpt-5\.4/);
|
||
assert.equal(reply?.senderLabel ?? "", "主Agent·gpt-5.4");
|
||
});
|
||
|
||
test("master-agent 查询当前是什么大模型时直接走 fast path 返回当前模型摘要", async () => {
|
||
await saveAiAccount({
|
||
accountId: "openai-fast-query",
|
||
label: "OpenAI 主模型",
|
||
role: "primary",
|
||
provider: "openai_api",
|
||
displayName: "OpenAI 主模型",
|
||
model: "gpt-5.4",
|
||
apiKey: "sk-openai-fast-query",
|
||
enabled: true,
|
||
setActive: true,
|
||
loginStatusNote: "用于当前模型查询测试。",
|
||
});
|
||
await updateProjectAgentControls(
|
||
"master-agent",
|
||
{
|
||
fastModelOverride: "gpt-5.4-mini",
|
||
smartModelOverride: "gpt-5.4",
|
||
},
|
||
"17600003315",
|
||
);
|
||
|
||
const response = await POST(
|
||
await createAuthedRequest("master-agent", {
|
||
body: "你现在是什么大模型",
|
||
}),
|
||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||
);
|
||
|
||
assert.equal(response.status, 200);
|
||
const payload = (await response.json()) as {
|
||
ok: boolean;
|
||
task?: { taskId: string } | null;
|
||
masterReplyState?: "queued" | "running" | "completed" | null;
|
||
};
|
||
assert.equal(payload.ok, true);
|
||
assert.equal(payload.task ?? null, null);
|
||
assert.equal(payload.masterReplyState, "completed");
|
||
|
||
const state = await readState();
|
||
const masterProject = state.projects.find((project) => project.id === "master-agent");
|
||
const reply = masterProject?.messages.at(-1);
|
||
assert.ok(reply, "expected the master-agent fast model summary reply to be persisted");
|
||
assert.match(reply?.body ?? "", /当前聊天模型:gpt-5\.4-mini/);
|
||
assert.match(reply?.body ?? "", /强模型:gpt-5\.4/);
|
||
assert.equal(reply?.senderLabel ?? "", "主Agent·gpt-5.4-mini");
|
||
});
|
||
|
||
test("master-agent 查询当前后端时直接走 fast path 返回后端摘要", async () => {
|
||
await saveAiAccount({
|
||
accountId: "openai-backend-query",
|
||
label: "OpenAI 主模型",
|
||
role: "primary",
|
||
provider: "openai_api",
|
||
displayName: "OpenAI 主模型",
|
||
model: "gpt-5.4",
|
||
apiKey: "sk-openai-backend-query",
|
||
enabled: true,
|
||
setActive: true,
|
||
loginStatusNote: "用于后端查询测试。",
|
||
});
|
||
await updateProjectAgentControls(
|
||
"master-agent",
|
||
{
|
||
backendOverride: "hermes-runtime",
|
||
},
|
||
"17600003315",
|
||
);
|
||
|
||
const response = await POST(
|
||
await createAuthedRequest("master-agent", {
|
||
body: "当前后端是什么",
|
||
}),
|
||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||
);
|
||
|
||
assert.equal(response.status, 200);
|
||
const payload = (await response.json()) as {
|
||
ok: boolean;
|
||
task?: { taskId: string } | null;
|
||
masterReplyState?: "queued" | "running" | "completed" | null;
|
||
};
|
||
assert.equal(payload.ok, true);
|
||
assert.equal(payload.task ?? null, null);
|
||
assert.equal(payload.masterReplyState, "completed");
|
||
|
||
const state = await readState();
|
||
const masterProject = state.projects.find((project) => project.id === "master-agent");
|
||
const reply = masterProject?.messages.at(-1);
|
||
assert.ok(reply, "expected the master-agent backend summary reply to be persisted");
|
||
assert.match(reply?.body ?? "", /当前后端:hermes-runtime/);
|
||
assert.equal(reply?.senderLabel ?? "", "主Agent·gpt-5.4");
|
||
});
|
||
|
||
test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异步实际回复时继承当前会话覆盖", async () => {
|
||
await saveAiAccount({
|
||
accountId: "openai-master-agent-queue",
|
||
label: "API 容灾",
|
||
role: "api_fallback",
|
||
provider: "openai_api",
|
||
displayName: "OpenAI API 队列测试",
|
||
model: "gpt-5.4",
|
||
apiKey: "sk-test-openai-queue",
|
||
enabled: true,
|
||
setActive: true,
|
||
loginStatusNote: "用于 master-agent 队列测试。",
|
||
});
|
||
|
||
await updateProjectAgentControls("master-agent", {
|
||
modelOverride: "gpt-4.1-mini",
|
||
reasoningEffortOverride: "high",
|
||
});
|
||
|
||
const fetchCalls: Array<{ url: string; body: unknown }> = [];
|
||
const originalFetch = globalThis.fetch;
|
||
globalThis.fetch = (async (input, init) => {
|
||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body ?? null;
|
||
fetchCalls.push({ url: String(input), body });
|
||
return new Response(JSON.stringify({ output_text: "已切到异步队列回复。" }), {
|
||
status: 200,
|
||
headers: {
|
||
"content-type": "application/json",
|
||
"x-request-id": "req-master-agent-queue",
|
||
},
|
||
});
|
||
}) as typeof fetch;
|
||
|
||
try {
|
||
const response = await POST(
|
||
await createAuthedRequest("master-agent", {
|
||
body: "请同步 master-agent 当前阻塞点",
|
||
}),
|
||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||
);
|
||
|
||
assert.equal(response.status, 200);
|
||
|
||
const payload = (await response.json()) as {
|
||
ok: boolean;
|
||
message: { id: string };
|
||
task?: { taskId: string; taskType: string; status: string } | null;
|
||
masterReplyState?: "queued" | "running" | "completed";
|
||
masterReply?: { accountId?: string } | null;
|
||
};
|
||
|
||
assert.equal(payload.ok, true);
|
||
assert.equal(payload.masterReply?.accountId, "openai-master-agent-queue");
|
||
assert.equal(payload.masterReplyState, "queued");
|
||
assert.ok(payload.task, "expected master-agent message to return a task envelope");
|
||
assert.equal(payload.task?.taskType, "conversation_reply");
|
||
assert.equal(payload.task?.status, "queued");
|
||
assert.ok(payload.task?.taskId, "expected a stable taskId in the response");
|
||
assert.equal((payload.task as { requestMessageId?: string } | null)?.requestMessageId, payload.message.id);
|
||
|
||
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.ok(task, "expected the queued task to remain in state");
|
||
assert.equal(task?.status, "completed");
|
||
assert.equal(task?.replyBody, "已切到异步队列回复。");
|
||
|
||
const masterProject = nextState.projects.find((project) => project.id === "master-agent");
|
||
const mirroredReply = masterProject?.messages.at(-1);
|
||
assert.ok(mirroredReply, "expected the async reply to be written back to the master-agent ledger");
|
||
assert.match(mirroredReply?.body ?? "", /已切到异步队列回复/);
|
||
|
||
assert.equal(fetchCalls.length, 1);
|
||
assert.equal(fetchCalls[0]?.url, "https://api.openai.com/v1/responses");
|
||
const requestBody = fetchCalls[0]?.body as {
|
||
model?: string;
|
||
reasoning?: { effort?: string };
|
||
};
|
||
assert.equal(requestBody?.model, "gpt-4.1-mini");
|
||
assert.equal(requestBody?.reasoning?.effort, "high");
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队列而不是挂到本机设备队列", async () => {
|
||
await saveAiAccount({
|
||
accountId: "master-codex-primary-offline",
|
||
label: "主 GPT",
|
||
role: "primary",
|
||
provider: "master_codex_node",
|
||
displayName: "离线 Master Codex Node",
|
||
nodeId: "offline-node",
|
||
nodeLabel: "离线节点",
|
||
model: "gpt-5.4",
|
||
enabled: true,
|
||
setActive: true,
|
||
loginStatusNote: "离线主节点",
|
||
});
|
||
|
||
await saveAiAccount({
|
||
accountId: "openai-backup-queue",
|
||
label: "备用 GPT",
|
||
role: "backup",
|
||
provider: "openai_api",
|
||
displayName: "OpenAI 备用账号",
|
||
accountIdentifier: "sk-queue-demo",
|
||
model: "gpt-5.4",
|
||
apiKey: "sk-queue-demo",
|
||
enabled: true,
|
||
setActive: false,
|
||
loginStatusNote: "备用 API 账号",
|
||
});
|
||
|
||
const originalFetch = globalThis.fetch;
|
||
globalThis.fetch = (async () =>
|
||
new Response(JSON.stringify({ output_text: "离线主节点已切到 API 后台队列。" }), {
|
||
status: 200,
|
||
headers: {
|
||
"content-type": "application/json",
|
||
"x-request-id": "req-master-agent-offline-fallback-queue",
|
||
},
|
||
})) as typeof fetch;
|
||
|
||
try {
|
||
const response = await POST(
|
||
await createAuthedRequest("master-agent", {
|
||
body: "请走备用 API 队列",
|
||
}),
|
||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||
);
|
||
|
||
assert.equal(response.status, 200);
|
||
const payload = (await response.json()) as {
|
||
ok: boolean;
|
||
task?: { taskId: string; taskType: string; status: string } | null;
|
||
masterReplyState?: "queued" | "running" | "completed";
|
||
masterReply?: { accountId?: string } | null;
|
||
};
|
||
assert.equal(payload.ok, true);
|
||
assert.equal(payload.masterReply?.accountId, "openai-backup-queue");
|
||
assert.equal(payload.masterReplyState, "queued");
|
||
assert.equal(payload.task?.taskType, "conversation_reply");
|
||
|
||
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?.deviceId, "master-agent-openai");
|
||
assert.equal(task?.status, "completed");
|
||
assert.equal(task?.accountId, "openai-backup-queue");
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
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 在显式选择 hermes-runtime 时会通过 Hermes 异步回写回复", async () => {
|
||
const hermesDir = await mkdtemp(path.join(os.tmpdir(), "boss-hermes-queue-"));
|
||
const hermesScriptPath = path.join(hermesDir, "hermes-runtime.mjs");
|
||
await writeFile(
|
||
hermesScriptPath,
|
||
`
|
||
const args = process.argv.slice(2);
|
||
const queryIndex = args.findIndex((item) => item === "-q" || item === "--query");
|
||
const query = queryIndex >= 0 ? args[queryIndex + 1] ?? "" : "";
|
||
process.stdout.write("Hermes 已接管当前主 Agent 会话:" + query + "\\n\\n");
|
||
process.stdout.write("session_id: hermes-session-123\\n");
|
||
`,
|
||
"utf8",
|
||
);
|
||
|
||
const previousEnv = {
|
||
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
|
||
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
|
||
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
|
||
BOSS_HERMES_TIMEOUT_MS: process.env.BOSS_HERMES_TIMEOUT_MS,
|
||
};
|
||
process.env.BOSS_HERMES_ENABLED = "true";
|
||
process.env.BOSS_HERMES_COMMAND = process.execPath;
|
||
process.env.BOSS_HERMES_ARGS = hermesScriptPath;
|
||
process.env.BOSS_HERMES_TIMEOUT_MS = "1000";
|
||
|
||
await saveAiAccount({
|
||
accountId: "master-codex-primary-hermes",
|
||
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: "用于 Hermes backend 队列测试。",
|
||
});
|
||
|
||
await updateProjectAgentControls("master-agent", {
|
||
backendOverride: "hermes-runtime",
|
||
});
|
||
|
||
try {
|
||
const response = await POST(
|
||
await createAuthedRequest("master-agent", {
|
||
body: "请走 Hermes 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, "hermes-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.match(task?.replyBody ?? "", /Hermes 已接管当前主 Agent 会话:/);
|
||
assert.match(task?.replyBody ?? "", /请走 Hermes runtime/);
|
||
assert.equal(task?.sessionId, "hermes-session-123");
|
||
|
||
const masterProject = nextState.projects.find((project) => project.id === "master-agent");
|
||
const mirroredReply = masterProject?.messages.at(-1);
|
||
assert.match(mirroredReply?.body ?? "", /Hermes 已接管当前主 Agent 会话/);
|
||
} finally {
|
||
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
|
||
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
|
||
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
|
||
process.env.BOSS_HERMES_TIMEOUT_MS = previousEnv.BOSS_HERMES_TIMEOUT_MS;
|
||
await rm(hermesDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("master-agent enqueue 在首选主节点离线时会回退到可用的备用主节点并返回实际账号", async () => {
|
||
await saveAiAccount({
|
||
accountId: "master-codex-primary-offline",
|
||
label: "主 GPT",
|
||
role: "primary",
|
||
provider: "master_codex_node",
|
||
displayName: "离线 Master Codex Node",
|
||
nodeId: "offline-node",
|
||
nodeLabel: "离线节点",
|
||
model: "gpt-5.4",
|
||
enabled: true,
|
||
setActive: true,
|
||
loginStatusNote: "离线主节点",
|
||
});
|
||
|
||
await saveAiAccount({
|
||
accountId: "master-codex-backup-online",
|
||
label: "备用主节点",
|
||
role: "backup",
|
||
provider: "master_codex_node",
|
||
displayName: "在线备用 Master Codex Node",
|
||
nodeId: "mac-studio",
|
||
nodeLabel: "Mac Studio",
|
||
model: "gpt-5.4",
|
||
enabled: true,
|
||
setActive: false,
|
||
loginStatusNote: "在线备用主节点",
|
||
});
|
||
|
||
const response = await POST(
|
||
await createAuthedRequest("master-agent", {
|
||
body: "请走备用主节点队列",
|
||
}),
|
||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||
);
|
||
|
||
assert.equal(response.status, 200);
|
||
const payload = (await response.json()) as {
|
||
ok: boolean;
|
||
task?: { taskId: string; taskType: string; status: string } | null;
|
||
masterReplyState?: "queued" | "running" | "completed";
|
||
masterReply?: { accountId?: string } | null;
|
||
};
|
||
|
||
assert.equal(payload.ok, true);
|
||
assert.equal(payload.masterReply?.accountId, "master-codex-backup-online");
|
||
assert.equal(payload.masterReplyState, "queued");
|
||
assert.equal(payload.task?.taskType, "conversation_reply");
|
||
assert.equal(payload.task?.status, "queued");
|
||
|
||
const state = await readState();
|
||
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
|
||
assert.ok(task, "expected queued master-agent task");
|
||
assert.equal(task?.accountId, "master-codex-backup-online");
|
||
assert.equal(task?.deviceId, "mac-studio");
|
||
});
|
||
|
||
test("master-agent enqueue 会在首个 API 候选失败后切到下一条备用链并重写任务账号", async () => {
|
||
await saveAiAccount({
|
||
accountId: "openai-primary-queue",
|
||
label: "OpenAI 主控",
|
||
role: "primary",
|
||
provider: "openai_api",
|
||
displayName: "OpenAI 主账号",
|
||
model: "gpt-5.4",
|
||
apiKey: "sk-openai-primary-queue",
|
||
enabled: true,
|
||
setActive: true,
|
||
loginStatusNote: "OpenAI 主控",
|
||
});
|
||
|
||
await saveAiAccount({
|
||
accountId: "aliyun-qwen-backup-queue",
|
||
label: "阿里备用",
|
||
role: "backup",
|
||
provider: "aliyun_qwen_api",
|
||
displayName: "阿里百炼备用账号",
|
||
model: "qwen3.5-plus",
|
||
apiKey: "sk-aliyun-backup-queue",
|
||
enabled: true,
|
||
setActive: false,
|
||
loginStatusNote: "阿里备用账号",
|
||
});
|
||
|
||
const fetchCalls: string[] = [];
|
||
const originalFetch = globalThis.fetch;
|
||
globalThis.fetch = (async (input) => {
|
||
fetchCalls.push(String(input));
|
||
if (typeof input === "string" && input === "https://api.openai.com/v1/responses") {
|
||
return new Response(JSON.stringify({ error: { message: "openai queue failure" } }), {
|
||
status: 500,
|
||
headers: { "content-type": "application/json" },
|
||
});
|
||
}
|
||
if (typeof input === "string" && input === "https://dashscope.aliyuncs.com/compatible-mode/v1/responses") {
|
||
return new Response(JSON.stringify({ output_text: "后台队列已切到阿里备用。" }), {
|
||
status: 200,
|
||
headers: {
|
||
"content-type": "application/json",
|
||
"x-request-id": "req-master-agent-queue-fallback-chain",
|
||
},
|
||
});
|
||
}
|
||
throw new Error(`unexpected fetch: ${String(input)}`);
|
||
}) as typeof fetch;
|
||
|
||
try {
|
||
const response = await POST(
|
||
await createAuthedRequest("master-agent", {
|
||
body: "请让后台队列自动切备用链",
|
||
}),
|
||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||
);
|
||
|
||
assert.equal(response.status, 200);
|
||
const payload = (await response.json()) as {
|
||
ok: boolean;
|
||
task?: { taskId: string; taskType: string; status: string } | null;
|
||
masterReplyState?: "queued" | "running" | "completed";
|
||
masterReply?: { accountId?: string } | null;
|
||
};
|
||
|
||
assert.equal(payload.ok, true);
|
||
assert.equal(payload.masterReply?.accountId, "openai-primary-queue");
|
||
assert.equal(payload.masterReplyState, "queued");
|
||
|
||
await waitFor(async () => {
|
||
const state = await readState();
|
||
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
|
||
return task?.status === "completed";
|
||
});
|
||
|
||
const state = await readState();
|
||
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
|
||
assert.ok(task, "expected queued task to remain in state");
|
||
assert.equal(task?.status, "completed");
|
||
assert.equal(task?.accountId, "aliyun-qwen-backup-queue");
|
||
assert.equal(task?.deviceId, "master-agent-aliyun-qwen");
|
||
const aliyunAccount = state.aiAccounts.find((item) => item.accountId === "aliyun-qwen-backup-queue");
|
||
assert.equal(aliyunAccount?.isActive, true);
|
||
assert.equal(fetchCalls[0], "https://api.openai.com/v1/responses");
|
||
assert.equal(fetchCalls[1], "https://dashscope.aliyuncs.com/compatible-mode/v1/responses");
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
});
|