feat: complete chat routing and openai onboarding

This commit is contained in:
kris
2026-03-31 03:31:22 +08:00
parent 5b590f7cc1
commit 9c02ebb574
25 changed files with 2241 additions and 133 deletions

View File

@@ -0,0 +1,190 @@
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 openAiOnboardRoute: (typeof import("../src/app/api/v1/accounts/onboard/openai-api/route"))["POST"];
let masterNodeOnboardRoute: (typeof import("../src/app/api/v1/accounts/onboard/master-node/route"))["POST"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let AUTH_SESSION_COOKIE: string;
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-ai-onboard-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [openAiModule, masterNodeModule, data, auth] = await Promise.all([
import("../src/app/api/v1/accounts/onboard/openai-api/route.ts"),
import("../src/app/api/v1/accounts/onboard/master-node/route.ts"),
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
]);
openAiOnboardRoute = openAiModule.POST;
masterNodeOnboardRoute = masterNodeModule.POST;
createAuthSession = data.createAuthSession;
readState = data.readState;
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
async function createAuthedJsonRequest(url: string, body: Record<string, unknown>) {
const session = await createAuthSession({
account: "17600003315",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
return new NextRequest(url, {
method: "POST",
headers: {
"content-type": "application/json",
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
},
body: JSON.stringify(body),
});
}
test("POST /api/v1/accounts/onboard/openai-api creates a primary openai account and activates it", async () => {
await setup();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input, init) => {
if (typeof input === "string" && input === "https://api.openai.com/v1/responses") {
assert.equal(init?.method, "POST");
return new Response(JSON.stringify({ output_text: "连接正常" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-onboard-openai",
},
});
}
throw new Error(`unexpected fetch: ${String(input)}`);
}) as typeof fetch;
try {
const response = await openAiOnboardRoute(
await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts/onboard/openai-api", {
label: "主 GPT",
displayName: "OpenAI 平台账号",
accountIdentifier: "sk-proj-demo",
model: "gpt-5.4",
apiKey: "sk-live-demo-123456",
}),
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
accountId: string;
message: string;
activeIdentity: { accountId: string; provider: string; displayName: string };
};
assert.equal(payload.ok, true);
assert.equal(payload.accountId, "openai-api-primary");
assert.equal(payload.activeIdentity.accountId, "openai-api-primary");
assert.equal(payload.activeIdentity.provider, "openai_api");
assert.match(payload.message, /已登录/);
assert.match(payload.message, /当前主控/);
const state = await readState();
const account = state.aiAccounts.find((item) => item.accountId === "openai-api-primary");
assert.ok(account, "expected openai primary account to be created");
assert.equal(account?.provider, "openai_api");
assert.equal(account?.role, "primary");
assert.equal(account?.isActive, true);
assert.equal(account?.status, "ready");
assert.equal(account?.displayName, "OpenAI 平台账号");
assert.equal(account?.apiKey, "sk-live-demo-123456");
assert.match(account?.apiKeyMasked ?? "", /\.\.\./);
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/v1/accounts/onboard/master-node upserts a master node account and returns bound-device guidance", async () => {
await setup();
const response = await masterNodeOnboardRoute(
await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts/onboard/master-node", {
label: "主 GPT",
displayName: "Mac 上的 Master Codex Node",
accountIdentifier: "17600003315",
nodeId: "mac-studio",
nodeLabel: "Mac Studio",
model: "gpt-5.4",
setActive: true,
}),
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
accountId: string;
message: string;
validation: { status: string; message: string };
};
assert.equal(payload.ok, true);
assert.equal(payload.accountId, "master-codex-primary");
assert.match(payload.message, /当前主控|已绑定/);
assert.equal(payload.validation.status, "ready");
assert.match(payload.validation.message, /不在手机里直接登录/);
assert.match(payload.validation.message, /Mac Studio|绑定设备/);
const state = await readState();
const account = state.aiAccounts.find((item) => item.accountId === "master-codex-primary");
assert.ok(account, "expected master node primary account");
assert.equal(account?.provider, "master_codex_node");
assert.equal(account?.displayName, "Mac 上的 Master Codex Node");
assert.equal(account?.nodeId, "mac-studio");
assert.equal(account?.nodeLabel, "Mac Studio");
assert.equal(account?.isActive, true);
});
test("POST /api/v1/accounts/onboard/openai-api returns a clear network guidance when OpenAI is unreachable", async () => {
await setup();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
const error = new TypeError("fetch failed");
(error as TypeError & { cause?: { code?: string; message?: string } }).cause = {
code: "ENETUNREACH",
message: "connect ENETUNREACH api.openai.com",
};
throw error;
}) as typeof fetch;
try {
const response = await openAiOnboardRoute(
await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts/onboard/openai-api", {
label: "主 GPT",
displayName: "OpenAI 平台账号",
accountIdentifier: "sk-proj-demo",
model: "gpt-5.4",
apiKey: "sk-live-demo-123456",
}),
);
assert.equal(response.status, 400);
const payload = (await response.json()) as { ok: boolean; message: string };
assert.equal(payload.ok, false);
assert.match(payload.message, /无法访问 api\.openai\.com|无法连接 OpenAI API/);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -8,7 +8,7 @@ import { NextRequest } from "next/server";
let runtimeRoot = "";
let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let createProjectGroupChat: (typeof import("../src/lib/boss-data"))["createProjectGroupChat"];
let createIndependentGroupChat: (typeof import("../src/lib/boss-data"))["createIndependentGroupChat"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let AUTH_SESSION_COOKIE: string;
@@ -30,7 +30,7 @@ async function setup() {
POST = routePost;
createAuthSession = data.createAuthSession;
createProjectGroupChat = data.createProjectGroupChat;
createIndependentGroupChat = data.createIndependentGroupChat;
readState = data.readState;
writeState = data.writeState;
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
@@ -62,38 +62,67 @@ async function createAuthedRequest(projectId: string, body: { body: string; kind
async function ensureTwoSingleThreadProjects() {
const state = await readState();
const singles = state.projects.filter((project) => project.id !== "master-agent" && !project.isGroup);
if (singles.length >= 2) {
return singles;
}
const seed = state.projects.find((project) => project.id !== "master-agent" && !project.isGroup);
assert.ok(seed, "expected at least one seeded single-thread project");
assert.ok(singles[0], "expected at least one seeded single-thread project");
const seed = singles[0];
const clonedProject = {
const primaryProject = {
...seed,
id: "boss-console-clone",
id: "dispatch-thread-a",
name: "Boss 移动控制台主线程",
deviceIds: ["mac-studio"],
updatedAt: "2026-03-30T10:00:00+08:00",
lastMessageAt: "2026-03-30T10:00:00+08:00",
preview: "主线程正在等待汇总今天的联调阻塞点。",
threadMeta: {
...seed.threadMeta,
projectId: "dispatch-thread-a",
threadId: "thread-dispatch-a",
threadDisplayName: "北区试产线回归",
folderName: "阻塞梳理",
updatedAt: "2026-03-30T10:00:00+08:00",
codexThreadRef: "thread-dispatch-a",
codexFolderRef: "/Users/kris/code/boss",
},
groupMembers: [],
messages: [
{
id: "msg-dispatch-a",
sender: "device" as const,
senderLabel: "Mac Studio / Codex",
body: "主线程还在等待主 Agent 汇总阻塞点。",
sentAt: "2026-03-30T10:00:00+08:00",
kind: "text" as const,
},
],
goals: [],
versions: [],
};
const secondaryProject = {
...seed,
id: "dispatch-thread-b",
name: "Boss 移动控制台副线程",
deviceIds: ["win-gpu-01"],
deviceIds: ["mac-studio"],
updatedAt: "2026-03-30T10:00:00+08:00",
lastMessageAt: "2026-03-30T10:00:00+08:00",
preview: "副线程等待主 Agent 汇总阻塞点。",
threadMeta: {
...seed.threadMeta,
projectId: "boss-console-clone",
threadId: "thread-boss-ui-clone",
projectId: "dispatch-thread-b",
threadId: "thread-dispatch-b",
threadDisplayName: "南区试产线回归",
folderName: "阻塞梳理",
updatedAt: "2026-03-30T10:00:00+08:00",
codexThreadRef: "thread-boss-ui-clone",
codexFolderRef: "boss-console-clone",
codexThreadRef: "thread-dispatch-b",
codexFolderRef: "/Users/kris/code/boss",
},
groupMembers: [],
messages: [
{
id: "msg-boss-console-clone",
id: "msg-dispatch-b",
sender: "device" as const,
senderLabel: "Win GPU / Codex",
body: "这里还在等待视觉链路复核。",
senderLabel: "Mac Studio / Codex",
body: "副线程还在等待视觉链路复核。",
sentAt: "2026-03-30T10:00:00+08:00",
kind: "text" as const,
},
@@ -104,11 +133,12 @@ async function ensureTwoSingleThreadProjects() {
await writeState({
...state,
projects: [...state.projects, clonedProject],
projects: state.projects
.filter((project) => !["dispatch-thread-a", "dispatch-thread-b"].includes(project.id))
.concat(primaryProject, secondaryProject),
});
const nextState = await readState();
return nextState.projects.filter((project) => project.id !== "master-agent" && !project.isGroup);
return [primaryProject, secondaryProject];
}
test("POST /api/v1/projects/[projectId]/messages returns a dispatch plan for group text messages", async () => {
@@ -116,9 +146,8 @@ test("POST /api/v1/projects/[projectId]/messages returns a dispatch plan for gro
const memberProjects = await ensureTwoSingleThreadProjects();
assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects");
const groupProject = await createProjectGroupChat({
sourceProjectId: memberProjects[0].id,
memberProjectIds: [memberProjects[1].id],
const groupProject = await createIndependentGroupChat({
memberProjectIds: [memberProjects[0].id, memberProjects[1].id],
createdBy: "17600003315",
});
@@ -195,9 +224,8 @@ test("POST /api/v1/projects/[projectId]/messages marks approval_required groups
const memberProjects = await ensureTwoSingleThreadProjects();
assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects");
const groupProject = await createProjectGroupChat({
sourceProjectId: memberProjects[0].id,
memberProjectIds: [memberProjects[1].id],
const groupProject = await createIndependentGroupChat({
memberProjectIds: [memberProjects[0].id, memberProjects[1].id],
createdBy: "17600003315",
});
@@ -249,9 +277,8 @@ test("POST /api/v1/projects/[projectId]/messages keeps message success when grou
const memberProjects = await ensureTwoSingleThreadProjects();
assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects");
const groupProject = await createProjectGroupChat({
sourceProjectId: memberProjects[0].id,
memberProjectIds: [memberProjects[1].id],
const groupProject = await createIndependentGroupChat({
memberProjectIds: [memberProjects[0].id, memberProjects[1].id],
createdBy: "17600003315",
});
@@ -306,6 +333,16 @@ test("POST /api/v1/projects/[projectId]/messages keeps message success when grou
.find((project) => project.id === groupProject.id)
?.messages.find((message) => message.id === payload.message.id);
assert.ok(savedMessage, "expected user message to remain persisted even when dispatch recommendation fails");
const persistedMessages =
nextState.projects.find((project) => project.id === groupProject.id)?.messages ?? [];
const savedMessageIndex = persistedMessages.findIndex((message) => message.id === payload.message.id);
assert.notEqual(savedMessageIndex, -1, "expected the user message to remain in the project timeline");
assert.ok(
persistedMessages
.slice(savedMessageIndex + 1)
.some((message) => message.sender === "master"),
"expected a user-visible failure notice to be appended for dispatch errors",
);
const failedTask = nextState.masterAgentTasks.find(
(task) =>
@@ -316,3 +353,76 @@ test("POST /api/v1/projects/[projectId]/messages keeps message success when grou
assert.ok(failedTask, "expected failed dispatch recommendation task to be recorded");
assert.equal(failedTask?.status, "failed");
});
test("POST /api/v1/projects/[projectId]/messages excludes master-agent from group dispatch targets", async () => {
await setup();
const memberProjects = await ensureTwoSingleThreadProjects();
assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects");
const groupProject = await createIndependentGroupChat({
memberProjectIds: [memberProjects[0].id, memberProjects[1].id],
createdBy: "17600003315",
});
const state = await readState();
await writeState({
...state,
projects: state.projects.map((project) =>
project.id === groupProject.id
? {
...project,
groupMembers: [
...project.groupMembers,
{
projectId: "master-agent",
deviceId: "mac-studio",
threadId: "thread-master-agent",
threadDisplayName: "主 Agent",
folderName: "主控线程",
},
],
}
: project,
),
});
const response = await POST(await createAuthedRequest(groupProject.id, { body: "请继续同步联调进展" }), {
params: Promise.resolve({ projectId: groupProject.id }),
});
assert.equal(response.status, 200);
const payload = (await response.json()) as {
dispatchPlan: null | {
targets: Array<{ projectId: string }>;
};
};
assert.ok(payload.dispatchPlan, "expected dispatch plan");
assert.deepEqual(
payload.dispatchPlan?.targets.map((target) => target.projectId),
groupProject.groupMembers.map((member) => member.projectId),
);
assert.equal(
payload.dispatchPlan?.targets.some((target) => target.projectId === "master-agent"),
false,
"master-agent should never appear as a dispatch target",
);
});
test("createIndependentGroupChat rejects non-thread members like master-agent", async () => {
await setup();
const state = await readState();
const realThread = state.projects.find(
(project) => project.id !== "master-agent" && !project.isGroup && Boolean(project.threadMeta.codexThreadRef),
);
assert.ok(realThread, "expected a real thread-backed project");
await assert.rejects(
() =>
createIndependentGroupChat({
memberProjectIds: ["master-agent", realThread.id],
createdBy: "17600003315",
}),
/GROUP_CHAT_MEMBER_NOT_THREAD/,
);
});

View File

@@ -0,0 +1,93 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildCodexTaskExecution } from "../local-agent/codex-task-runner.mjs";
test("conversation reply resumes the real Codex thread when thread ref is available", () => {
const execution = buildCodexTaskExecution(
{
masterAgentWorkdir: "/Users/kris/code/boss",
masterAgentSandbox: "workspace-write",
masterAgentModel: "gpt-5.4",
},
{
taskType: "conversation_reply",
executionPrompt: "请回复用户",
targetCodexThreadRef: "019d-thread-real",
targetCodexFolderRef: "/Users/kris/code/meiyesaas",
},
"/tmp/reply.txt",
);
assert.equal(execution.mode, "resume");
assert.equal(execution.cwd, "/Users/kris/code/meiyesaas");
assert.deepEqual(execution.args, [
"exec",
"resume",
"--skip-git-repo-check",
"-o",
"/tmp/reply.txt",
"-m",
"gpt-5.4",
"019d-thread-real",
"请回复用户",
]);
});
test("dispatch execution falls back to targetThreadId when codex thread ref is missing", () => {
const execution = buildCodexTaskExecution(
{
masterAgentWorkdir: "/Users/kris/code/boss",
masterAgentSandbox: "workspace-write",
},
{
taskType: "dispatch_execution",
executionPrompt: "请执行群聊任务",
targetThreadId: "019d-thread-fallback",
},
"/tmp/reply.txt",
);
assert.equal(execution.mode, "resume");
assert.deepEqual(execution.args, [
"exec",
"resume",
"--skip-git-repo-check",
"-o",
"/tmp/reply.txt",
"019d-thread-fallback",
"请执行群聊任务",
]);
});
test("master agent reply without target thread stays on ephemeral exec", () => {
const execution = buildCodexTaskExecution(
{
masterAgentWorkdir: "/Users/kris/code/boss",
masterAgentSandbox: "workspace-write",
masterAgentModel: "gpt-5.4",
},
{
taskType: "conversation_reply",
executionPrompt: "你是主 Agent",
},
"/tmp/master.txt",
);
assert.equal(execution.mode, "ephemeral");
assert.equal(execution.cwd, "/Users/kris/code/boss");
assert.deepEqual(execution.args, [
"exec",
"--ephemeral",
"--skip-git-repo-check",
"-C",
"/Users/kris/code/boss",
"-s",
"workspace-write",
"-o",
"/tmp/master.txt",
"-m",
"gpt-5.4",
"你是主 Agent",
]);
});

View File

@@ -0,0 +1,102 @@
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";
let runtimeRoot = "";
let replyToMasterAgentUserMessage: (typeof import("../src/lib/boss-master-agent"))["replyToMasterAgentUserMessage"];
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-fallback-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [masterAgent, data] = await Promise.all([
import("../src/lib/boss-master-agent.ts"),
import("../src/lib/boss-data.ts"),
]);
replyToMasterAgentUserMessage = masterAgent.replyToMasterAgentUserMessage;
saveAiAccount = data.saveAiAccount;
readState = data.readState;
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test("replyToMasterAgentUserMessage falls back to a runnable OpenAI API account when the master node is offline", async () => {
await setup();
await saveAiAccount({
accountId: "master-codex-primary",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "Mac 上的 Master Codex Node",
nodeId: "offline-node",
nodeLabel: "离线节点",
model: "gpt-5.4",
enabled: true,
setActive: true,
loginStatusNote: "通过绑定的 Master Codex Node 对话。",
});
await saveAiAccount({
accountId: "openai-backup",
label: "备用 GPT",
role: "backup",
provider: "openai_api",
displayName: "OpenAI API 备用账号",
accountIdentifier: "sk-demo",
model: "gpt-5.4",
apiKey: "sk-live-demo-123456",
enabled: true,
setActive: false,
loginStatusNote: "备用 API 账号。",
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
if (typeof input === "string" && input === "https://api.openai.com/v1/responses") {
return new Response(JSON.stringify({ output_text: "主Agent链路正常。" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-master-fallback",
},
});
}
throw new Error(`unexpected fetch: ${String(input)}`);
}) as typeof fetch;
try {
const result = await replyToMasterAgentUserMessage({
requestMessageId: "msg-master-fallback",
requestText: "请只回复主Agent链路正常。",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
});
assert.equal(result.ok, true);
assert.equal(result.accountId, "openai-backup");
assert.equal(result.requestId, "req-master-fallback");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected a master-agent reply to be appended");
assert.equal(reply?.sender, "master");
assert.equal(reply?.senderLabel, "主 Agent · 备用 GPT");
assert.match(reply?.body ?? "", /主Agent链路正常/);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,155 @@
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 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;
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);
}
test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task for single-thread projects", async () => {
await setup();
const state = await readState();
const singleProject = findSingleThreadProject(state);
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);
});
test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread reply back to the single-thread project", async () => {
await setup();
const state = await readState();
const singleProject = findSingleThreadProject(state);
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");
});