feat: refine mobile master agent sync and chat rendering
This commit is contained in:
@@ -12,6 +12,7 @@ let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSessio
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let AUTH_SESSION_COOKIE = "";
|
||||
const TEST_ACCOUNT = "17600003315";
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) {
|
||||
@@ -45,7 +46,7 @@ test.after(async () => {
|
||||
|
||||
async function createAuthedRequest(url: string, method: "POST", body: unknown) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: TEST_ACCOUNT,
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -120,10 +121,41 @@ async function ensureSingleThreadProject() {
|
||||
return findSingleThreadProject(nextState);
|
||||
}
|
||||
|
||||
async function setProjectTakeover(projectId: string, enabled: boolean) {
|
||||
const state = await readState();
|
||||
state.userProjectAgentControls = state.userProjectAgentControls.filter(
|
||||
(item) => !(item.projectId === projectId && item.account === TEST_ACCOUNT),
|
||||
);
|
||||
state.userProjectAgentControls.unshift({
|
||||
account: TEST_ACCOUNT,
|
||||
projectId,
|
||||
controls: {
|
||||
takeoverEnabled: enabled,
|
||||
updatedAt: enabled ? "2026-04-17T10:00:00.000Z" : "2026-04-17T10:10:00.000Z",
|
||||
},
|
||||
});
|
||||
await writeState(state);
|
||||
}
|
||||
|
||||
async function resetThreadExecutionState(projectId: string) {
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
const targetDevice = project ? state.devices.find((device) => device.id === project.deviceIds[0]) : null;
|
||||
if (targetDevice) {
|
||||
targetDevice.preferredExecutionMode = "cli";
|
||||
}
|
||||
state.projectExecutionPolicies = state.projectExecutionPolicies.filter((policy) => policy.projectId !== projectId);
|
||||
state.userProjectAgentControls = state.userProjectAgentControls.filter(
|
||||
(item) => !(item.projectId === projectId && item.account === TEST_ACCOUNT),
|
||||
);
|
||||
await writeState(state);
|
||||
}
|
||||
|
||||
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");
|
||||
await resetThreadExecutionState(singleProject.id);
|
||||
|
||||
const response = await postMessageRoute(
|
||||
await createAuthedRequest(
|
||||
@@ -157,17 +189,146 @@ test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task fo
|
||||
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.equal(task?.targetCodexThreadRef, singleProject.threadMeta.codexThreadRef);
|
||||
assert.equal(task?.targetCodexFolderRef, singleProject.threadMeta.codexFolderRef);
|
||||
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");
|
||||
assert.equal(task?.relayViaMasterAgent, undefined);
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/[projectId]/messages routes takeover mode to master-agent conversation first", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
assert.ok(singleProject, "expected a seeded single-thread project");
|
||||
await resetThreadExecutionState(singleProject.id);
|
||||
await setProjectTakeover(singleProject.id, true);
|
||||
|
||||
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;
|
||||
replyPresenter?: "thread" | "master";
|
||||
task?: { taskId: string; taskType: string; status: string } | null;
|
||||
};
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.replyPresenter, "master");
|
||||
assert.equal(payload.task?.taskType, "conversation_reply");
|
||||
|
||||
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 takeover mode");
|
||||
assert.equal(task?.relayViaMasterAgent, true);
|
||||
assert.equal(task?.targetProjectId, undefined);
|
||||
assert.equal(task?.targetThreadId, undefined);
|
||||
assert.equal(task?.targetCodexThreadRef, undefined);
|
||||
assert.equal(task?.targetCodexFolderRef, undefined);
|
||||
assert.ok(task?.executionPrompt?.includes("主 Agent"));
|
||||
assert.ok(task?.executionPrompt?.includes("协同接管"));
|
||||
assert.ok(task?.executionPrompt?.includes("先准确理解并确认用户意图"));
|
||||
});
|
||||
|
||||
test("takeover prompt asks master agent to sync verified goals and version records when user requests review", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
assert.ok(singleProject, "expected a seeded single-thread project");
|
||||
await resetThreadExecutionState(singleProject.id);
|
||||
await setProjectTakeover(singleProject.id, true);
|
||||
|
||||
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;
|
||||
};
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.task?.taskType, "conversation_reply");
|
||||
|
||||
const nextState = await readState();
|
||||
const task = nextState.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
|
||||
assert.ok(task, "expected a queued takeover task");
|
||||
assert.equal(task?.relayViaMasterAgent, true);
|
||||
assert.match(task!.executionPrompt, /用户要求核对或更新项目目标、版本记录时/);
|
||||
assert.match(task!.executionPrompt, /先让当前线程基于本地开发文档和实际代码重新汇总/);
|
||||
assert.match(task!.executionPrompt, /自动同步到当前会话顶部的“项目目标”和“版本记录”入口/);
|
||||
|
||||
const understandingTask = nextState.masterAgentTasks.find(
|
||||
(item) =>
|
||||
item.projectId === "master-agent" &&
|
||||
item.projectUnderstandingTargetProjectId === singleProject.id &&
|
||||
item.status === "queued",
|
||||
);
|
||||
assert.ok(understandingTask, "expected a hidden project understanding sync task");
|
||||
assert.match(understandingTask!.executionPrompt, /先基于当前项目本地可见的开发文档和实际代码进行汇总/);
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/[projectId]/messages still lets takeover mode talk to master agent during gui conflict", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
assert.ok(singleProject, "expected a seeded single-thread project");
|
||||
await resetThreadExecutionState(singleProject.id);
|
||||
await setProjectTakeover(singleProject.id, true);
|
||||
|
||||
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, 200);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
replyPresenter?: "thread" | "master";
|
||||
task?: { taskId: string; taskType: string; status: string } | null;
|
||||
};
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.replyPresenter, "master");
|
||||
assert.equal(payload.task?.taskType, "conversation_reply");
|
||||
|
||||
const nextState = await readState();
|
||||
const task = nextState.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
|
||||
assert.ok(task, "expected takeover mode to queue a master-agent task");
|
||||
assert.equal(task?.relayViaMasterAgent, true);
|
||||
assert.equal(task?.targetProjectId, undefined);
|
||||
});
|
||||
|
||||
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");
|
||||
await setProjectTakeover(singleProject.id, false);
|
||||
|
||||
const state = await readState();
|
||||
const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]);
|
||||
@@ -227,6 +388,7 @@ test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
assert.ok(singleProject, "expected a seeded single-thread project");
|
||||
await setProjectTakeover(singleProject.id, false);
|
||||
|
||||
const state = await readState();
|
||||
const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]);
|
||||
@@ -289,12 +451,139 @@ test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when
|
||||
assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task");
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/[projectId]/messages blocks before queueing when recent codex activity exists without a stored policy", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
assert.ok(singleProject, "expected a seeded single-thread project");
|
||||
await resetThreadExecutionState(singleProject.id);
|
||||
await setProjectTakeover(singleProject.id, false);
|
||||
|
||||
const recentExternalActivityAt = new Date(Date.now() - 60_000).toISOString();
|
||||
const state = await readState();
|
||||
await writeState({
|
||||
...state,
|
||||
projects: state.projects.map((project) =>
|
||||
project.id === singleProject.id
|
||||
? {
|
||||
...project,
|
||||
threadMeta: {
|
||||
...project.threadMeta,
|
||||
lastObservedCodexActivityAt: recentExternalActivityAt,
|
||||
},
|
||||
}
|
||||
: project,
|
||||
),
|
||||
projectExecutionPolicies: state.projectExecutionPolicies.filter(
|
||||
(policy) => policy.projectId !== singleProject.id,
|
||||
),
|
||||
});
|
||||
|
||||
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;
|
||||
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?.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/projects/[projectId]/messages ignores stale scoped conflict policies", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
assert.ok(singleProject, "expected a seeded single-thread project");
|
||||
await resetThreadExecutionState(singleProject.id);
|
||||
|
||||
const staleExternalActivityAt = new Date(Date.now() - 30 * 60_000).toISOString();
|
||||
const state = await readState();
|
||||
await writeState({
|
||||
...state,
|
||||
projects: state.projects.map((project) =>
|
||||
project.id === singleProject.id
|
||||
? {
|
||||
...project,
|
||||
threadMeta: {
|
||||
...project.threadMeta,
|
||||
lastObservedCodexActivityAt: staleExternalActivityAt,
|
||||
},
|
||||
}
|
||||
: project,
|
||||
),
|
||||
projectExecutionPolicies: [
|
||||
...state.projectExecutionPolicies.filter((policy) => policy.projectId !== singleProject.id),
|
||||
{
|
||||
deviceId: singleProject.deviceIds[0],
|
||||
folderKey: buildProjectFolderKey(singleProject),
|
||||
projectId: singleProject.id,
|
||||
allowPolicy: "forbid" as const,
|
||||
conflictState: "blocked" as const,
|
||||
recentExternalActivityAt: staleExternalActivityAt,
|
||||
updatedAt: staleExternalActivityAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.task?.taskType, "conversation_reply");
|
||||
assert.equal(payload.task?.status, "queued");
|
||||
});
|
||||
|
||||
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 resetThreadExecutionState(singleProject.id);
|
||||
await setProjectTakeover(singleProject.id, false);
|
||||
|
||||
await postMessageRoute(
|
||||
const sendResponse = await postMessageRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
|
||||
"POST",
|
||||
@@ -302,13 +591,13 @@ test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread re
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: singleProject.id }) },
|
||||
);
|
||||
const sendPayload = (await sendResponse.json()) as {
|
||||
task?: { taskId: string };
|
||||
};
|
||||
|
||||
const queuedState = await readState();
|
||||
const task = queuedState.masterAgentTasks.find(
|
||||
(item) =>
|
||||
item.taskType === "conversation_reply" &&
|
||||
item.projectId === singleProject.id &&
|
||||
item.targetProjectId === singleProject.id,
|
||||
(item) => item.taskId === sendPayload.task?.taskId,
|
||||
);
|
||||
assert.ok(task, "expected a queued conversation_reply task");
|
||||
|
||||
@@ -337,10 +626,66 @@ test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread re
|
||||
assert.equal(mirroredReply?.sender, "device");
|
||||
});
|
||||
|
||||
test("POST /api/v1/master-agent/tasks/[taskId]/complete writes takeover master replies to the current project", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
assert.ok(singleProject, "expected a seeded single-thread project");
|
||||
await resetThreadExecutionState(singleProject.id);
|
||||
await setProjectTakeover(singleProject.id, true);
|
||||
|
||||
const sendResponse = 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 sendPayload = (await sendResponse.json()) as {
|
||||
task?: { taskId: string };
|
||||
};
|
||||
|
||||
const queuedState = await readState();
|
||||
const task = queuedState.masterAgentTasks.find(
|
||||
(item) => item.taskId === sendPayload.task?.taskId,
|
||||
);
|
||||
assert.ok(task, "expected a queued conversation_reply task");
|
||||
assert.equal(task?.relayViaMasterAgent, true);
|
||||
assert.equal(task?.targetProjectId, undefined);
|
||||
assert.equal(task?.targetThreadId, undefined);
|
||||
|
||||
await setProjectTakeover(singleProject.id, false);
|
||||
|
||||
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",
|
||||
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 relayedReply = updatedProject?.messages.find((message) =>
|
||||
message.body.includes("我先确认一下:你是希望我梳理当前阻塞后,再协调目标线程继续推进,对吗?"),
|
||||
);
|
||||
assert.ok(relayedReply, "expected a master reply to be written back to the current project");
|
||||
assert.equal(relayedReply?.sender, "master");
|
||||
assert.match(relayedReply?.senderLabel ?? "", /主 Agent/);
|
||||
});
|
||||
|
||||
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 resetThreadExecutionState(singleProject.id);
|
||||
await setProjectTakeover(singleProject.id, false);
|
||||
|
||||
await postMessageRoute(
|
||||
await createAuthedRequest(
|
||||
|
||||
Reference in New Issue
Block a user