Files
boss/tests/single-thread-message-execution.test.ts

392 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { NextRequest } from "next/server";
let runtimeRoot = "";
let postMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
let completeMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route"))["POST"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let AUTH_SESSION_COOKIE = "";
async function setup() {
if (runtimeRoot) {
return;
}
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-single-thread-message-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [messageModule, completeModule, data, auth] = await Promise.all([
import("../src/app/api/v1/projects/[projectId]/messages/route.ts"),
import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts"),
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
]);
postMessageRoute = messageModule.POST;
completeMasterTaskRoute = completeModule.POST;
createAuthSession = data.createAuthSession;
readState = data.readState;
writeState = data.writeState;
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
async function createAuthedRequest(url: string, method: "POST", body: unknown) {
const session = await createAuthSession({
account: "17600003315",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
return new NextRequest(url, {
method,
headers: {
"content-type": "application/json",
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
},
body: JSON.stringify(body),
});
}
function findSingleThreadProject(
state: Awaited<ReturnType<typeof readState>>,
) {
return state.projects.find((project) => project.id !== "master-agent" && !project.isGroup);
}
function buildSingleThreadProject(projectId: string) {
return {
id: projectId,
name: "测试线程",
pinned: false,
systemPinned: false,
deviceIds: ["mac-studio"],
preview: "测试线程等待继续处理。",
updatedAt: "2026-04-04T11:30:00+08:00",
lastMessageAt: "2026-04-04T11:30:00+08:00",
isGroup: false,
threadMeta: {
projectId,
threadId: `${projectId}-thread`,
threadDisplayName: "测试线程",
folderName: "测试项目",
activityIconCount: 0,
updatedAt: "2026-04-04T11:30:00+08:00",
codexThreadRef: `${projectId}-thread`,
codexFolderRef: `/Users/kris/code/${projectId}`,
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development" as const,
approvalState: "not_required" as const,
unreadCount: 0,
riskLevel: "low" as const,
messages: [],
goals: [],
versions: [],
};
}
function buildProjectFolderKey(project: ReturnType<typeof buildSingleThreadProject>) {
const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase();
return `${project.deviceIds[0]}:${folderRef}`;
}
async function ensureSingleThreadProject() {
const state = await readState();
const existing = findSingleThreadProject(state);
if (existing) {
return existing;
}
const project = buildSingleThreadProject("single-thread-test");
await writeState({
...state,
projects: state.projects.concat(project),
});
const nextState = await readState();
return findSingleThreadProject(nextState);
}
test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task for single-thread projects", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "请同步一下当前阻塞情况" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string; taskType: string; status: string } | null;
dispatchPlan: null;
};
assert.equal(payload.ok, true);
assert.equal(payload.dispatchPlan, null);
assert.ok(payload.task, "expected single-thread message to return a queued task");
assert.equal(payload.task?.taskType, "conversation_reply");
assert.equal(payload.task?.status, "queued");
const nextState = await readState();
const task = nextState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "请同步一下当前阻塞情况",
);
assert.ok(task, "expected a queued conversation_reply task for the single-thread project");
assert.equal(task?.targetProjectId, singleProject.id);
assert.equal(task?.targetThreadId, singleProject.threadMeta.threadId);
assert.ok(task?.executionPrompt?.includes("请同步一下当前阻塞情况"));
assert.ok(task?.executionPrompt?.includes(singleProject.threadMeta.threadDisplayName));
assert.ok(!task?.executionPrompt?.includes("threadProjectId:"), "thread prompt should not include project id labels");
assert.ok(!task?.executionPrompt?.includes("folderName:"), "thread prompt should not include folder labels");
assert.ok(!task?.executionPrompt?.includes("deviceIds:"), "thread prompt should not include device id labels");
});
test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when the target device prefers gui mode", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
const state = await readState();
const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]);
assert.ok(targetDevice, "expected a seeded target device");
targetDevice.preferredExecutionMode = "gui";
await writeState(state);
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "继续推进当前线程" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
assert.equal(response.status, 409);
const payload = (await response.json()) as {
ok: boolean;
code?: string;
message?: string;
executionConflict?: {
projectId: string;
deviceId: string;
preferredExecutionMode: "gui" | "cli";
allowPolicy: "forbid" | "allow_once" | "allow_always";
conflictState: "none" | "warning" | "blocked";
reason: string;
actions: string[];
};
};
assert.equal(payload.ok, false);
assert.equal(payload.code, "THREAD_EXECUTION_CONFLICT");
assert.equal(payload.executionConflict?.projectId, singleProject.id);
assert.equal(payload.executionConflict?.deviceId, singleProject.deviceIds[0]);
assert.equal(payload.executionConflict?.preferredExecutionMode, "gui");
assert.equal(payload.executionConflict?.allowPolicy, "forbid");
assert.equal(payload.executionConflict?.conflictState, "blocked");
assert.equal(payload.executionConflict?.reason, "preferred_gui_mode");
assert.deepEqual(payload.executionConflict?.actions, ["forbid", "allow_once", "allow_always"]);
const nextState = await readState();
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
const blockedMessage = updatedProject?.messages.find((message) => message.body.includes("继续推进当前线程"));
assert.equal(blockedMessage, undefined, "blocked send should not append a local chat message");
const queuedTask = nextState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "继续推进当前线程",
);
assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task");
});
test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when the current project folder is forbidden", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
const state = await readState();
const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]);
assert.ok(targetDevice, "expected a seeded target device");
targetDevice.preferredExecutionMode = "cli";
state.projectExecutionPolicies = [
{
deviceId: singleProject.deviceIds[0],
folderKey: buildProjectFolderKey(singleProject),
projectId: singleProject.id,
allowPolicy: "forbid",
conflictState: "blocked",
updatedAt: "2026-04-06T13:20:00.000Z",
},
];
await writeState(state);
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "继续同步项目进度" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
assert.equal(response.status, 409);
const payload = (await response.json()) as {
ok: boolean;
code?: string;
executionConflict?: {
projectId: string;
folderKey?: string;
preferredExecutionMode: "gui" | "cli";
allowPolicy: "forbid" | "allow_once" | "allow_always";
conflictState: "none" | "warning" | "blocked";
reason: string;
};
};
assert.equal(payload.ok, false);
assert.equal(payload.code, "THREAD_EXECUTION_CONFLICT");
assert.equal(payload.executionConflict?.projectId, singleProject.id);
assert.equal(payload.executionConflict?.folderKey, buildProjectFolderKey(singleProject));
assert.equal(payload.executionConflict?.preferredExecutionMode, "cli");
assert.equal(payload.executionConflict?.allowPolicy, "forbid");
assert.equal(payload.executionConflict?.conflictState, "blocked");
assert.equal(payload.executionConflict?.reason, "project_conflict_forbid");
const nextState = await readState();
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
const blockedMessage = updatedProject?.messages.find((message) => message.body.includes("继续同步项目进度"));
assert.equal(blockedMessage, undefined, "blocked send should not append a local chat message");
const queuedTask = nextState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "继续同步项目进度",
);
assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task");
});
test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread reply back to the single-thread project", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "请同步一下当前阻塞情况" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
const queuedState = await readState();
const task = queuedState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.targetProjectId === singleProject.id,
);
assert.ok(task, "expected a queued conversation_reply task");
const response = await completeMasterTaskRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/master-agent/tasks/${task.taskId}/complete`,
"POST",
{
deviceId: task.deviceId,
status: "completed",
targetProjectId: singleProject.id,
targetThreadId: singleProject.threadMeta.threadId,
replyBody: "当前阻塞点已经同步:视觉验收待今晚回归。",
},
),
{ params: Promise.resolve({ taskId: task.taskId }) },
);
assert.equal(response.status, 200);
const nextState = await readState();
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
const mirroredReply = updatedProject?.messages.find((message) =>
message.body.includes("当前阻塞点已经同步:视觉验收待今晚回归。"),
);
assert.ok(mirroredReply, "expected single-thread reply to be written back to the project");
assert.equal(mirroredReply?.sender, "device");
});
test("POST /api/v1/master-agent/tasks/[taskId]/complete blocks leaked thread environment diagnostics from the chat transcript", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "请继续推进当前线程" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
const queuedState = await readState();
const task = queuedState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.targetProjectId === singleProject.id,
);
assert.ok(task, "expected a queued conversation_reply task");
const response = await completeMasterTaskRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/master-agent/tasks/${task.taskId}/complete`,
"POST",
{
deviceId: task.deviceId,
status: "completed",
targetProjectId: singleProject.id,
targetThreadId: singleProject.threadMeta.threadId,
replyBody:
"我不能直接把当前会话环境从只读改回可写也不能替你修改这层运行配置。cwd 我可以在命令里指向 /Users/kris/code/gptpluscontrol。",
},
),
{ params: Promise.resolve({ taskId: task.taskId }) },
);
assert.equal(response.status, 200);
const nextState = await readState();
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
const leakedReply = updatedProject?.messages.find((message) =>
message.body.includes("当前会话环境从只读改回可写"),
);
assert.equal(leakedReply, undefined);
const opsNotice = updatedProject?.messages.find((message) =>
message.body.includes("线程环境异常,请重新绑定到正确项目或工作目录后再试。"),
);
assert.ok(opsNotice, "expected a user-facing system notice instead of raw environment diagnostics");
});