chore: checkpoint Boss app v2.5.11
This commit is contained in:
@@ -398,6 +398,12 @@ test("backoffice bff exposes yudao style management contract without secrets", a
|
||||
const staleTask = payload.insights.taskRiskSummary.rows.find((row: { taskId: string }) => row.taskId === "task-stale");
|
||||
assert.equal(staleTask?.stale, true);
|
||||
assert.equal(staleTask?.phase, "awaiting_reply");
|
||||
assert.equal(Array.isArray(payload.insights.taskSlaPanel.rows), true);
|
||||
assert.equal(payload.insights.taskSlaPanel.summary.breached >= 1, true);
|
||||
const breachedTask = payload.insights.taskSlaPanel.rows.find((row: { taskId: string }) => row.taskId === "task-stale");
|
||||
assert.equal(breachedTask?.slaLevel, "breached");
|
||||
assert.equal(breachedTask?.riskId, "master-task:task-stale");
|
||||
assert.equal(typeof breachedTask?.recommendedAction, "string");
|
||||
assert.equal(payload.yudaoMapping.tenant, "adminCompanies");
|
||||
assert.equal(payload.yudaoMapping.user, "authAccounts");
|
||||
assert.equal(payload.yudaoMapping.role, "BOSS_PERMISSION_TEMPLATES");
|
||||
|
||||
@@ -39,6 +39,7 @@ test("Caddy serves the platform admin subdomain", async () => {
|
||||
const source = await readSource(caddyfilePath);
|
||||
|
||||
assert.match(source, /admin\.boss\.hyzq\.net/);
|
||||
assert.match(source, /handle \/admin-web\/\* \{\s*root \* \/opt\/boss\/public\s*file_server\s*\}/s);
|
||||
assert.match(source, /@adminRoot path \//);
|
||||
assert.match(source, /rewrite \* \/admin-web\/index\.html/);
|
||||
assert.doesNotMatch(source, /redir \/ \/enterprise-admin/);
|
||||
|
||||
@@ -208,3 +208,97 @@ test("risk scan creates operational faults for computer use and boss-agent OTA f
|
||||
const secondPayload = await second.json();
|
||||
assert.equal(secondPayload.createdFaults.length, 0);
|
||||
});
|
||||
|
||||
test("risk scan creates SLA notifications for stuck master agent tasks", async () => {
|
||||
const state = await data.readState();
|
||||
await data.writeState({
|
||||
...state,
|
||||
adminNotifications: [],
|
||||
masterAgentTasks: [
|
||||
{
|
||||
taskId: "task-stuck",
|
||||
projectId: "project-a",
|
||||
taskType: "conversation_reply",
|
||||
requestMessageId: "msg-stuck",
|
||||
requestText: "让线程继续执行",
|
||||
executionPrompt: "继续执行并回写结果",
|
||||
requestedBy: "客户负责人",
|
||||
requestedByAccount: "customer@example.com",
|
||||
deviceId: "mac-a",
|
||||
status: "running",
|
||||
phase: "awaiting_reply",
|
||||
requestedAt: "2026-04-27T13:00:00+08:00",
|
||||
claimedAt: "2026-04-27T13:01:00+08:00",
|
||||
lastProgressAt: "2026-04-27T13:01:00+08:00",
|
||||
leaseExpiresAt: "2026-04-27T13:16:00+08:00",
|
||||
attemptCount: 1,
|
||||
maxAttempts: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
|
||||
method: "POST",
|
||||
}));
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(
|
||||
payload.created.some((notification: { riskId: string }) => notification.riskId === "master-task:task-stuck"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
payload.notifications.some((notification: { title: string }) => notification.title.includes("任务 SLA")),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("risk scan automatically requeues safely recoverable master agent tasks", async () => {
|
||||
const state = await data.readState();
|
||||
await data.writeState({
|
||||
...state,
|
||||
adminNotifications: [],
|
||||
permissionAuditLogs: [],
|
||||
adminRiskTimeline: [],
|
||||
masterAgentTasks: [
|
||||
{
|
||||
taskId: "task-recoverable",
|
||||
projectId: "project-a",
|
||||
taskType: "conversation_reply",
|
||||
requestMessageId: "msg-recoverable",
|
||||
requestText: "继续处理",
|
||||
executionPrompt: "继续处理并回写结果",
|
||||
requestedBy: "客户负责人",
|
||||
requestedByAccount: "customer@example.com",
|
||||
deviceId: "mac-a",
|
||||
status: "running",
|
||||
phase: "recoverable_failed",
|
||||
requestedAt: "2026-04-27T13:00:00+08:00",
|
||||
claimedAt: "2026-04-27T13:01:00+08:00",
|
||||
lastProgressAt: "2026-04-27T13:02:00+08:00",
|
||||
leaseExpiresAt: "2026-04-27T13:16:00+08:00",
|
||||
attemptCount: 1,
|
||||
maxAttempts: 2,
|
||||
recoverable: true,
|
||||
nextRetryAt: "2026-04-27T13:03:00+08:00",
|
||||
lastErrorCode: "RECOVERABLE_RUNTIME_FAILURE",
|
||||
errorMessage: "CODEX_APP_SERVER_TIMEOUT",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
|
||||
method: "POST",
|
||||
}));
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.autoRecovered.length, 1);
|
||||
assert.equal(payload.autoRecovered[0].taskId, "task-recoverable");
|
||||
|
||||
const nextState = await data.readState();
|
||||
const task = nextState.masterAgentTasks.find((item) => item.taskId === "task-recoverable");
|
||||
assert.equal(task?.status, "queued");
|
||||
assert.equal(task?.phase, "queued");
|
||||
assert.equal(task?.recoverable, false);
|
||||
assert.equal(nextState.permissionAuditLogs.some((log) => log.action === "master_agent.task_retried"), true);
|
||||
assert.equal(nextState.adminRiskTimeline.some((event) => event.action === "task.auto_recovery_requeued"), true);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ test("BossApiClient exposes a lightweight project messages endpoint", async () =
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/return requestWithRestore\("GET", "\/api\/v1\/projects\/" \+ encode\(projectId\) \+ "\/messages", null\);/,
|
||||
/return requestWithRestoreRaw\(\s*"GET",\s*"\/api\/v1\/projects\/" \+ encode\(projectId\) \+ "\/messages",\s*null,\s*DEFAULT_CONNECT_TIMEOUT_MS,\s*CONVERSATIONS_READ_TIMEOUT_MS\s*\);/s,
|
||||
"expected lightweight message refreshes to reuse the dedicated messages route",
|
||||
);
|
||||
});
|
||||
@@ -36,7 +36,7 @@ test("ProjectDetailActivity reserves full realtime reloads for non-message event
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/void triggerRealtimeReload\(boolean requireFullSnapshot\) \{\s*if \(requireFullSnapshot\) \{\s*reload\(\);\s*return;\s*\}\s*reloadMessagesOnly\(\);\s*\}/s,
|
||||
/void triggerRealtimeReload\(boolean requireFullSnapshot\) \{\s*if \(requireFullSnapshot\) \{\s*reloadInBackground\(false\);\s*return;\s*\}\s*reloadMessagesOnly\(\);\s*\}/s,
|
||||
"expected debounced realtime reloads to choose between full and lightweight refresh paths",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,13 +6,23 @@ async function readSource(path: string) {
|
||||
return readFile(new URL(path, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
test("events route enriches message events with a lightweight project chat payload", async () => {
|
||||
test("events route supports message patch v1 while retaining snapshot fallback", async () => {
|
||||
const source = await readSource("../src/app/api/v1/events/route.ts");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/projectMessagesPayload:\s*buildProjectMessagesRealtimePayload\(state,\s*String\(payload\.projectId \?\? ""\)\)/,
|
||||
"expected realtime event route to include a lightweight project chat payload for message events",
|
||||
/x-boss-realtime-capabilities/,
|
||||
"expected realtime event route to inspect native app capability headers",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/projectMessagesPatch/,
|
||||
"expected realtime event route to emit a message patch for capable clients",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/projectMessagesPayload:\s*buildProjectMessagesRealtimePayload/,
|
||||
"expected realtime event route to retain full snapshot fallback for older clients",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,6 +39,16 @@ test("ProjectDetailActivity applies lightweight realtime chat payloads before sc
|
||||
/JSONObject projectMessagesPayload = event\.payload\.optJSONObject\("projectMessagesPayload"\);/,
|
||||
"expected chat page to read the lightweight message payload from realtime events",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/JSONObject projectMessagesPatch = event\.payload\.optJSONObject\("projectMessagesPatch"\);/,
|
||||
"expected chat page to read message-patch-v1 realtime payloads first",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/scheduleRealtimeReload\(false\);/,
|
||||
"expected chat page to fall back to a debounced message reload when a patch has a gap",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/renderLoadedProjectSnapshot\(new ProjectSnapshot\(projectMessagesPayload,\s*null,\s*null\)\);/,
|
||||
|
||||
@@ -142,6 +142,30 @@ test("highest admin can inspect and revoke all active sessions", async () => {
|
||||
assert.equal(await data.getAuthSession(worker.sessionToken), null);
|
||||
});
|
||||
|
||||
test("getAuthSession validates a session without touching lastSeenAt", async () => {
|
||||
const session = await data.createAuthSession({
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss",
|
||||
loginMethod: "password",
|
||||
});
|
||||
const stableLastSeenAt = "2026-04-26T12:00:00+08:00";
|
||||
const state = await data.readState();
|
||||
const storedSession = state.authSessions.find((item) => item.sessionId === session.sessionId);
|
||||
assert.ok(storedSession);
|
||||
storedSession.lastSeenAt = stableLastSeenAt;
|
||||
await data.writeState(state);
|
||||
|
||||
const resolvedSession = await data.getAuthSession(session.sessionToken);
|
||||
assert.equal(resolvedSession?.lastSeenAt, stableLastSeenAt);
|
||||
|
||||
const after = await data.readState();
|
||||
assert.equal(
|
||||
after.authSessions.find((item) => item.sessionId === session.sessionId)?.lastSeenAt,
|
||||
stableLastSeenAt,
|
||||
);
|
||||
});
|
||||
|
||||
test("primary admin session uses the current production admin account", async () => {
|
||||
const session = await data.createPrimaryAdminSession();
|
||||
assert.equal(session.account, "krisolo");
|
||||
|
||||
@@ -134,6 +134,52 @@ test("independent Boss admin web app exposes management actions instead of read
|
||||
assert.match(appSource, /handleCodexRemoteControl/);
|
||||
});
|
||||
|
||||
test("independent Boss admin web app exposes skill management dispatch workspace", async () => {
|
||||
const [appSource, apiSource] = await Promise.all([
|
||||
readSource("../apps/boss-admin-web/src/App.vue"),
|
||||
readSource("../apps/boss-admin-web/src/api/bossAdmin.ts"),
|
||||
]);
|
||||
|
||||
assert.match(apiSource, /fetchSkillLifecycleRequests/);
|
||||
assert.match(apiSource, /\/api\/v1\/admin\/skills\/requests/);
|
||||
assert.match(apiSource, /method:\s*["']GET["']/);
|
||||
|
||||
for (const label of [
|
||||
"Skill 管理分发",
|
||||
"快捷下发",
|
||||
"Skill 请求队列",
|
||||
"待执行",
|
||||
"执行中",
|
||||
"最近请求",
|
||||
"安装远端 Skill",
|
||||
"更新下发",
|
||||
"回滚",
|
||||
"版本锁定",
|
||||
]) {
|
||||
assert.match(appSource, new RegExp(label));
|
||||
}
|
||||
assert.match(appSource, /skillLifecycleRequests/);
|
||||
assert.match(appSource, /loadSkillLifecycleRequests/);
|
||||
assert.match(appSource, /quickSkillRequest/);
|
||||
});
|
||||
|
||||
test("independent Boss admin web app keeps backup tables inside their cards", async () => {
|
||||
const [appSource, cssSource] = await Promise.all([
|
||||
readSource("../apps/boss-admin-web/src/App.vue"),
|
||||
readSource("../apps/boss-admin-web/src/styles.css"),
|
||||
]);
|
||||
|
||||
assert.match(appSource, /boss-admin-wide-card/);
|
||||
assert.match(cssSource, /\.ant-card\s*\{/);
|
||||
assert.match(cssSource, /\.boss-admin-wide-card/);
|
||||
assert.match(cssSource, /grid-column:\s*1\s*\/\s*-1/);
|
||||
assert.match(cssSource, /min-width:\s*0/);
|
||||
assert.match(cssSource, /\.ant-table-wrapper\s*\{/);
|
||||
assert.match(cssSource, /overflow-x:\s*auto/);
|
||||
assert.match(cssSource, /word-break:\s*break-word/);
|
||||
assert.match(cssSource, /white-space:\s*normal/);
|
||||
});
|
||||
|
||||
test("root Next project isolates the independent Vue admin workspace", async () => {
|
||||
const [tsconfigSource, eslintSource, rootPkgSource] = await Promise.all([
|
||||
readSource("../tsconfig.json"),
|
||||
|
||||
@@ -26,6 +26,21 @@ test("events route enriches project conversation events with a visible home item
|
||||
);
|
||||
});
|
||||
|
||||
test("events route coalesces enriched payload building across realtime clients", async () => {
|
||||
const source = await readSource("../src/app/api/v1/events/route.ts");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/getSharedEventPayload/,
|
||||
"expected realtime event route to share one enriched payload build across concurrent SSE clients",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/sharedEventPayloads/,
|
||||
"expected realtime event route to keep a short-lived shared payload cache",
|
||||
);
|
||||
});
|
||||
|
||||
test("MainActivity applies realtime conversation patches without forcing a network refresh", async () => {
|
||||
const [mainActivity, mapper] = await Promise.all([
|
||||
readSource("../android/app/src/main/java/com/hyzq/boss/MainActivity.java"),
|
||||
|
||||
@@ -180,7 +180,7 @@ test("allow_always applies only to the active folder and does not unlock other f
|
||||
assert.equal(blocked.policy.allowPolicy, "forbid");
|
||||
});
|
||||
|
||||
test("claimNextMasterAgentTask keeps conversation replies queued when the device prefers gui mode", async () => {
|
||||
test("claimNextMasterAgentTask lets conversation replies run through gui mode when a gui execution channel is available", async () => {
|
||||
await setup();
|
||||
|
||||
const project = await getCliProject();
|
||||
@@ -205,6 +205,49 @@ test("claimNextMasterAgentTask keeps conversation replies queued when the device
|
||||
|
||||
const claimed = await claimNextMasterAgentTask("mac-studio");
|
||||
|
||||
assert.equal(claimed?.taskId, task.taskId);
|
||||
const state = await readState();
|
||||
const running = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
||||
assert.equal(running?.status, "running");
|
||||
});
|
||||
|
||||
test("claimNextMasterAgentTask keeps conversation replies queued when preferred gui mode has no gui channel", async () => {
|
||||
await setup();
|
||||
|
||||
const project = await getCliProject();
|
||||
await updateDevice("mac-studio", {
|
||||
preferredExecutionMode: "gui",
|
||||
capabilities: {
|
||||
gui: {
|
||||
connected: false,
|
||||
lastSeenAt: "2026-04-06T10:00:00.000Z",
|
||||
lastActiveProjectId: "",
|
||||
},
|
||||
codexAppServer: {
|
||||
connected: false,
|
||||
lastSeenAt: "2026-04-06T10:00:00.000Z",
|
||||
lastActiveProjectId: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
const task = await queueMasterAgentTask({
|
||||
projectId: project.id,
|
||||
requestMessageId: "msg-preferred-gui-no-channel",
|
||||
requestText: "继续推进当前线程任务",
|
||||
executionPrompt: "请继续推进当前线程任务",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
taskType: "conversation_reply",
|
||||
targetProjectId: project.id,
|
||||
targetThreadId: project.threadMeta.threadId,
|
||||
targetThreadDisplayName: project.threadMeta.threadDisplayName,
|
||||
targetCodexThreadRef: project.threadMeta.codexThreadRef,
|
||||
targetCodexFolderRef: project.threadMeta.codexFolderRef,
|
||||
});
|
||||
|
||||
const claimed = await claimNextMasterAgentTask("mac-studio");
|
||||
|
||||
assert.equal(claimed, null);
|
||||
const state = await readState();
|
||||
const queued = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
||||
@@ -389,6 +432,7 @@ test("claimNextMasterAgentTask reclaims stale running conversation replies for t
|
||||
const runningTask = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
||||
assert.equal(runningTask?.status, "running");
|
||||
runningTask!.claimedAt = "2026-04-01T00:00:00.000Z";
|
||||
runningTask!.lastProgressAt = "2026-04-01T00:00:00.000Z";
|
||||
await writeState(state);
|
||||
|
||||
const reclaimed = await claimNextMasterAgentTask("mac-studio");
|
||||
|
||||
@@ -28,7 +28,7 @@ test.after(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("device heartbeat mirrors recent codex desktop replies into the matching thread conversation once", async () => {
|
||||
test("device heartbeat records recent codex desktop activity without mirroring reply text", async () => {
|
||||
await setup();
|
||||
|
||||
const seedHeartbeat = {
|
||||
@@ -70,6 +70,7 @@ test("device heartbeat mirrors recent codex desktop replies into the matching th
|
||||
projectCandidates: [
|
||||
{
|
||||
...seedHeartbeat.projectCandidates[0],
|
||||
lastActiveAt: "2026-04-20T10:02:10.000Z",
|
||||
recentAssistantMessages: [
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-main:2026-04-20T10:02:10.000Z:reply-1",
|
||||
@@ -87,13 +88,10 @@ test("device heartbeat mirrors recent codex desktop replies into the matching th
|
||||
(message) => message.externalMessageId === "codex-thread:thread-boss-main:2026-04-20T10:02:10.000Z:reply-1",
|
||||
);
|
||||
|
||||
assert.ok(mirroredMessage);
|
||||
assert.equal(mirroredMessage?.sender, "device");
|
||||
assert.equal(mirroredMessage?.senderLabel, "Boss开发主线程");
|
||||
assert.equal(mirroredMessage?.body, "桌面 Codex 已经把会话实时同步链路修好了。");
|
||||
assert.equal(nextProject?.lastMessageAt, "2026-04-20T10:02:10.000Z");
|
||||
assert.equal(nextProject?.preview, "桌面 Codex 已经把会话实时同步链路修好了。");
|
||||
assert.equal(nextProject?.unreadCount, 1);
|
||||
assert.equal(mirroredMessage, undefined);
|
||||
assert.equal(nextProject?.messages.some((message) => message.externalMessageId), false);
|
||||
assert.equal(nextProject?.threadMeta.lastObservedCodexActivityAt, "2026-04-20T10:02:10.000Z");
|
||||
assert.equal(nextProject?.unreadCount, 0);
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
...seedHeartbeat,
|
||||
@@ -116,8 +114,86 @@ test("device heartbeat mirrors recent codex desktop replies into the matching th
|
||||
const mirroredCopies = nextProject?.messages.filter(
|
||||
(message) => message.externalMessageId === "codex-thread:thread-boss-main:2026-04-20T10:02:10.000Z:reply-1",
|
||||
);
|
||||
assert.equal(mirroredCopies?.length, 1);
|
||||
assert.equal(nextProject?.unreadCount, 1);
|
||||
assert.equal(mirroredCopies?.length, 0);
|
||||
assert.equal(nextProject?.unreadCount, 0);
|
||||
});
|
||||
|
||||
test("device heartbeat records codex activity without appending uncorrelated desktop replies", async () => {
|
||||
await setup();
|
||||
|
||||
const seedHeartbeat = {
|
||||
deviceId: "device-message-activity-only",
|
||||
token: "device-message-activity-only-token",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "krisolo",
|
||||
status: "online" as const,
|
||||
quota5h: 76,
|
||||
quota7d: 85,
|
||||
projects: [],
|
||||
endpoint: "mac://kris.local",
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: "juyuwan",
|
||||
folderRef: "/Users/kris/Documents/juyuwan",
|
||||
threadId: "thread-juyuwan-activity-only",
|
||||
threadDisplayName: "juyuwan",
|
||||
codexFolderRef: "/Users/kris/Documents/juyuwan",
|
||||
codexThreadRef: "thread-juyuwan-activity-only",
|
||||
lastActiveAt: "2026-06-07T16:30:00.000Z",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
|
||||
const initialState = await readState();
|
||||
const importedProject = initialState.projects.find(
|
||||
(project) => project.threadMeta.codexThreadRef === "thread-juyuwan-activity-only",
|
||||
);
|
||||
assert.ok(importedProject, "expected heartbeat auto-import to create the thread conversation");
|
||||
importedProject!.messages = [];
|
||||
importedProject!.preview = "";
|
||||
importedProject!.unreadCount = 0;
|
||||
await writeState(initialState);
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
...seedHeartbeat,
|
||||
projectCandidates: [
|
||||
{
|
||||
...seedHeartbeat.projectCandidates[0],
|
||||
lastActiveAt: "2026-06-07T16:34:15.000Z",
|
||||
recentAssistantMessages: [
|
||||
{
|
||||
messageId: "codex-thread:thread-juyuwan-activity-only:2026-06-07T16:34:15.000Z:final-1",
|
||||
body: "已完成下一步,并实机验证了。APK 已安装到 K30 Ultra。",
|
||||
sentAt: "2026-06-07T16:34:15.000Z",
|
||||
phase: "final_answer",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nextState = await readState();
|
||||
const nextProject = nextState.projects.find((project) => project.id === importedProject?.id);
|
||||
const mirroredMessage = nextProject?.messages.find(
|
||||
(message) =>
|
||||
message.externalMessageId ===
|
||||
"codex-thread:thread-juyuwan-activity-only:2026-06-07T16:34:15.000Z:final-1",
|
||||
);
|
||||
const progressEvent = nextState.threadProgressEvents.find(
|
||||
(event) => event.projectId === importedProject?.id && event.createdAt === "2026-06-07T16:34:15.000Z",
|
||||
);
|
||||
|
||||
assert.equal(mirroredMessage, undefined);
|
||||
assert.equal(nextProject?.messages.length, 0);
|
||||
assert.equal(nextProject?.preview, "");
|
||||
assert.equal(nextProject?.unreadCount, 0);
|
||||
assert.equal(nextProject?.threadMeta.lastObservedCodexActivityAt, "2026-06-07T16:34:15.000Z");
|
||||
assert.ok(progressEvent, "expected heartbeat activity to remain visible as a thread progress event");
|
||||
});
|
||||
|
||||
test("device heartbeat does not duplicate a reply already written by task completion", async () => {
|
||||
@@ -283,7 +359,7 @@ test("device heartbeat does not duplicate a takeover reply already written by ma
|
||||
assert.equal(nextProject?.preview, replyBody);
|
||||
});
|
||||
|
||||
test("device heartbeat does not count commentary replies as unread and keeps only the final result unread", async () => {
|
||||
test("device heartbeat ignores commentary and final assistant text as chat messages", async () => {
|
||||
await setup();
|
||||
|
||||
const seedHeartbeat = {
|
||||
@@ -314,11 +390,22 @@ test("device heartbeat does not count commentary replies as unread and keeps onl
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
|
||||
const initialState = await readState();
|
||||
const importedProject = initialState.projects.find(
|
||||
(project) => project.threadMeta.codexThreadRef === "thread-boss-phase",
|
||||
);
|
||||
assert.ok(importedProject);
|
||||
importedProject!.messages = [];
|
||||
importedProject!.preview = "";
|
||||
importedProject!.unreadCount = 0;
|
||||
await writeState(initialState);
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
...seedHeartbeat,
|
||||
projectCandidates: [
|
||||
{
|
||||
...seedHeartbeat.projectCandidates[0],
|
||||
lastActiveAt: "2026-04-20T10:05:00.000Z",
|
||||
recentAssistantMessages: [
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-phase:2026-04-20T10:03:00.000Z:commentary-1",
|
||||
@@ -351,10 +438,12 @@ test("device heartbeat does not count commentary replies as unread and keeps onl
|
||||
);
|
||||
|
||||
assert.ok(nextProject);
|
||||
assert.equal(processMessage?.kind, "thread_process");
|
||||
assert.equal(finalMessage?.kind, "text");
|
||||
assert.equal(nextProject?.preview, "这轮已经完成折叠修复,未读现在只会算最终结果。");
|
||||
assert.equal(nextProject?.unreadCount, 1);
|
||||
assert.equal(processMessage, undefined);
|
||||
assert.equal(finalMessage, undefined);
|
||||
assert.equal(nextProject?.messages.length, 0);
|
||||
assert.equal(nextProject?.preview, "");
|
||||
assert.equal(nextProject?.unreadCount, 0);
|
||||
assert.equal(nextProject?.threadMeta.lastObservedCodexActivityAt, "2026-04-20T10:05:00.000Z");
|
||||
});
|
||||
|
||||
test("device heartbeat does not replay old desktop replies after conversation history is cleared", async () => {
|
||||
@@ -441,13 +530,14 @@ test("device heartbeat does not replay old desktop replies after conversation hi
|
||||
(message) =>
|
||||
message.externalMessageId === "codex-thread:thread-boss-reset:2026-04-20T10:11:00.000Z:new-final",
|
||||
),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert.equal(nextProject?.preview, "这条新回复应该继续同步回来。");
|
||||
assert.equal(nextProject?.unreadCount, 1);
|
||||
assert.equal(nextProject?.preview, "");
|
||||
assert.equal(nextProject?.unreadCount, 0);
|
||||
assert.equal(nextProject?.threadMeta.lastObservedCodexActivityAt, "2026-04-20T10:12:00.000Z");
|
||||
});
|
||||
|
||||
test("device heartbeat legacy process text is normalized to thread_process and does not become preview", async () => {
|
||||
test("device heartbeat process text is kept out of the chat transcript", async () => {
|
||||
await setup();
|
||||
|
||||
const seedHeartbeat = {
|
||||
@@ -480,6 +570,13 @@ test("device heartbeat legacy process text is normalized to thread_process and d
|
||||
|
||||
const resetState = await readState();
|
||||
resetState.conversationHistoryClearedAt = undefined;
|
||||
const importedProject = resetState.projects.find(
|
||||
(project) => project.threadMeta.codexThreadRef === "thread-boss-legacy-process",
|
||||
);
|
||||
assert.ok(importedProject);
|
||||
importedProject!.messages = [];
|
||||
importedProject!.preview = "";
|
||||
importedProject!.unreadCount = 0;
|
||||
await writeState(resetState);
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
@@ -487,6 +584,7 @@ test("device heartbeat legacy process text is normalized to thread_process and d
|
||||
projectCandidates: [
|
||||
{
|
||||
...seedHeartbeat.projectCandidates[0],
|
||||
lastActiveAt: "2026-04-20T10:05:00.000Z",
|
||||
recentAssistantMessages: [
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-legacy-process:2026-04-20T10:03:00.000Z:commentary-legacy",
|
||||
@@ -516,7 +614,9 @@ test("device heartbeat legacy process text is normalized to thread_process and d
|
||||
);
|
||||
|
||||
assert.ok(nextProject);
|
||||
assert.equal(legacyProcessMessage?.kind, "thread_process");
|
||||
assert.equal(nextProject?.preview, "这轮已经完成折叠修复,未读现在只会算最终结果。");
|
||||
assert.equal(nextProject?.unreadCount, 1);
|
||||
assert.equal(legacyProcessMessage, undefined);
|
||||
assert.equal(nextProject?.messages.length, 0);
|
||||
assert.equal(nextProject?.preview, "");
|
||||
assert.equal(nextProject?.unreadCount, 0);
|
||||
assert.equal(nextProject?.threadMeta.lastObservedCodexActivityAt, "2026-04-20T10:05:00.000Z");
|
||||
});
|
||||
|
||||
@@ -38,6 +38,12 @@ function buildHeartbeatPayload(deviceId: string, projectCandidates: Array<{
|
||||
codexFolderRef: string;
|
||||
codexThreadRef: string;
|
||||
lastActiveAt: string;
|
||||
recentAssistantMessages?: Array<{
|
||||
messageId: string;
|
||||
body: string;
|
||||
sentAt: string;
|
||||
phase?: "commentary" | "final_answer";
|
||||
}>;
|
||||
}>) {
|
||||
return {
|
||||
deviceId,
|
||||
@@ -84,6 +90,52 @@ test("unchanged device heartbeats do not publish conversation refresh events", a
|
||||
assert.deepEqual(events.map((event) => event.event), ["devices.updated"]);
|
||||
});
|
||||
|
||||
test("assistant observations refresh conversation metadata without publishing message refresh events", async () => {
|
||||
await setup();
|
||||
|
||||
const deviceId = "noise-device-assistant-observation";
|
||||
const heartbeat = buildHeartbeatPayload(deviceId, [bossThreadCandidate]);
|
||||
await upsertDeviceHeartbeat(heartbeat);
|
||||
await upsertDeviceHeartbeat(heartbeat);
|
||||
|
||||
const observedHeartbeat = buildHeartbeatPayload(deviceId, [
|
||||
{
|
||||
...bossThreadCandidate,
|
||||
lastActiveAt: "2026-04-10T10:02:00.000Z",
|
||||
recentAssistantMessages: [
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-main:2026-04-10T10:02:00.000Z:final",
|
||||
body: "桌面线程的最终回复不应作为手机聊天消息刷新。",
|
||||
sentAt: "2026-04-10T10:02:00.000Z",
|
||||
phase: "final_answer",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const firstEvents: Array<{ event: string; payload: { projectId?: string } }> = [];
|
||||
const unsubscribeFirst = subscribeBossEvents((event, payload) => {
|
||||
firstEvents.push({ event, payload });
|
||||
});
|
||||
await upsertDeviceHeartbeat(observedHeartbeat);
|
||||
unsubscribeFirst();
|
||||
|
||||
assert.equal(firstEvents.some((event) => event.event === "project.messages.updated"), false);
|
||||
assert.deepEqual(
|
||||
firstEvents.map((event) => event.event),
|
||||
["devices.updated", "conversation.updated"],
|
||||
);
|
||||
|
||||
const repeatedEvents: Array<{ event: string; payload: { projectId?: string } }> = [];
|
||||
const unsubscribeRepeated = subscribeBossEvents((event, payload) => {
|
||||
repeatedEvents.push({ event, payload });
|
||||
});
|
||||
await upsertDeviceHeartbeat(observedHeartbeat);
|
||||
unsubscribeRepeated();
|
||||
|
||||
assert.deepEqual(repeatedEvents.map((event) => event.event), ["devices.updated"]);
|
||||
});
|
||||
|
||||
test("device heartbeats publish one conversation refresh when import candidates change", async () => {
|
||||
await setup();
|
||||
|
||||
|
||||
21
tests/fixtures/codex-app-server-runtime.mjs
vendored
21
tests/fixtures/codex-app-server-runtime.mjs
vendored
@@ -575,6 +575,27 @@ rl.on("line", (line) => {
|
||||
}
|
||||
|
||||
if (message.method === "thread/resume") {
|
||||
if (process.env.BOSS_CODEX_APP_SERVER_FIXTURE_ACTIVE_TURN_ON_RESUME === "1") {
|
||||
send({
|
||||
id: message.id,
|
||||
result: {
|
||||
thread: {
|
||||
id: message.params?.threadId ?? "thread-fixture",
|
||||
name: "fixture thread",
|
||||
turns: [
|
||||
{
|
||||
id: "active-turn-from-resume",
|
||||
status: "inProgress",
|
||||
startedAt: 1780852200,
|
||||
completedAt: null,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
send({
|
||||
id: message.id,
|
||||
result: {
|
||||
|
||||
@@ -1355,6 +1355,49 @@ test("codex app-server runner steers an active turn when a target turn id is pre
|
||||
}
|
||||
});
|
||||
|
||||
test("codex app-server runner steers an active resumed turn instead of starting a competing turn", async () => {
|
||||
const previousActiveTurn = process.env.BOSS_CODEX_APP_SERVER_FIXTURE_ACTIVE_TURN_ON_RESUME;
|
||||
const previousSteer = process.env.BOSS_CODEX_APP_SERVER_FIXTURE_STEER;
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_ACTIVE_TURN_ON_RESUME = "1";
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_STEER = "1";
|
||||
try {
|
||||
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
|
||||
codexAppServerEnabled: true,
|
||||
codexAppServerCommand: process.execPath,
|
||||
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
|
||||
codexAppServerWorkdir: repoRoot,
|
||||
codexAppServerTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const result = await executeCodexAppServerTask(runnerConfig, {
|
||||
taskId: "task-auto-steer-active-turn",
|
||||
taskType: "conversation_reply",
|
||||
targetCodexThreadRef: "active-thread-from-resume",
|
||||
targetCodexFolderRef: repoRoot,
|
||||
mirrorBossUserMessageToCodexDesktop: true,
|
||||
executionPrompt: "手机端补充:继续下一步",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.threadId, "active-thread-from-resume");
|
||||
assert.equal(result.turnId, "active-turn-from-resume");
|
||||
assert.equal(result.turnControl, "steer");
|
||||
assert.equal(result.replyBody, "STEERED:手机端补充:继续下一步");
|
||||
assert.equal(result.threadHistorySync, undefined);
|
||||
} finally {
|
||||
if (previousActiveTurn === undefined) {
|
||||
delete process.env.BOSS_CODEX_APP_SERVER_FIXTURE_ACTIVE_TURN_ON_RESUME;
|
||||
} else {
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_ACTIVE_TURN_ON_RESUME = previousActiveTurn;
|
||||
}
|
||||
if (previousSteer === undefined) {
|
||||
delete process.env.BOSS_CODEX_APP_SERVER_FIXTURE_STEER;
|
||||
} else {
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_STEER = previousSteer;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("codex app-server runner interrupts the active turn when the task is canceled while running", async () => {
|
||||
const previous = process.env.BOSS_CODEX_APP_SERVER_FIXTURE_WAIT_FOR_INTERRUPT;
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_WAIT_FOR_INTERRUPT = "1";
|
||||
|
||||
21
tests/master-agent-complete-route-nonblocking.test.ts
Normal file
21
tests/master-agent-complete-route-nonblocking.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
test("master agent complete route does not block completion response on Telegram delivery", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/await\s+deliverTelegramReplyForCompletedTask/,
|
||||
"task completion must not wait for Telegram delivery before returning to local-agent",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/void\s+deliverTelegramReplyForCompletedTask/,
|
||||
"expected Telegram delivery to be scheduled in the background after the task is persisted",
|
||||
);
|
||||
});
|
||||
@@ -120,6 +120,79 @@ test("expired task after turn start is timed out instead of duplicated", async (
|
||||
assert.equal(task?.recoverable, false);
|
||||
});
|
||||
|
||||
test("recoverable codex app-server runtime failure requeues conversation reply", async () => {
|
||||
await setup();
|
||||
const state = await data.readState();
|
||||
state.masterAgentTasks.unshift(
|
||||
makeQueuedTask("task-runtime-retry", {
|
||||
projectId: "project-juyuwan",
|
||||
targetProjectId: "project-juyuwan",
|
||||
targetThreadId: "thread-juyuwan",
|
||||
status: "running",
|
||||
phase: "awaiting_reply",
|
||||
claimedAt: "2026-06-07T06:05:16.000Z",
|
||||
lastProgressAt: "2026-06-07T06:10:00.000Z",
|
||||
leaseExpiresAt: "2026-06-07T06:20:16.000Z",
|
||||
attemptCount: 1,
|
||||
maxAttempts: 2,
|
||||
}),
|
||||
);
|
||||
await data.writeState(state);
|
||||
|
||||
const completed = await data.completeMasterAgentTask({
|
||||
taskId: "task-runtime-retry",
|
||||
deviceId: "mac-studio",
|
||||
status: "failed",
|
||||
errorMessage: "CODEX_APP_SERVER_TURN_INTERRUPTED",
|
||||
});
|
||||
|
||||
assert.equal(completed.status, "queued");
|
||||
assert.equal(completed.phase, "recoverable_failed");
|
||||
assert.equal(completed.recoverable, true);
|
||||
assert.equal(completed.errorMessage, "CODEX_APP_SERVER_TURN_INTERRUPTED");
|
||||
assert.equal(completed.attemptCount, 1);
|
||||
assert.equal(completed.completedAt, undefined);
|
||||
assert.equal(completed.claimedAt, undefined);
|
||||
assert.ok(completed.nextRetryAt);
|
||||
|
||||
const claimed = await data.claimNextMasterAgentTask("mac-studio");
|
||||
assert.equal(claimed?.taskId, "task-runtime-retry");
|
||||
assert.equal(claimed?.attemptCount, 2);
|
||||
});
|
||||
|
||||
test("recoverable codex app-server runtime failure becomes terminal after max attempts", async () => {
|
||||
await setup();
|
||||
const state = await data.readState();
|
||||
state.masterAgentTasks.unshift(
|
||||
makeQueuedTask("task-runtime-terminal", {
|
||||
projectId: "project-juyuwan",
|
||||
targetProjectId: "project-juyuwan",
|
||||
targetThreadId: "thread-juyuwan",
|
||||
status: "running",
|
||||
phase: "awaiting_reply",
|
||||
claimedAt: "2026-06-07T06:05:16.000Z",
|
||||
lastProgressAt: "2026-06-07T06:10:00.000Z",
|
||||
leaseExpiresAt: "2026-06-07T06:20:16.000Z",
|
||||
attemptCount: 2,
|
||||
maxAttempts: 2,
|
||||
}),
|
||||
);
|
||||
await data.writeState(state);
|
||||
|
||||
const completed = await data.completeMasterAgentTask({
|
||||
taskId: "task-runtime-terminal",
|
||||
deviceId: "mac-studio",
|
||||
status: "failed",
|
||||
errorMessage: "CODEX_APP_SERVER_TIMEOUT",
|
||||
});
|
||||
|
||||
assert.equal(completed.status, "failed");
|
||||
assert.equal(completed.phase, "terminal_failed");
|
||||
assert.equal(completed.recoverable, false);
|
||||
assert.equal(completed.errorMessage, "CODEX_APP_SERVER_TIMEOUT");
|
||||
assert.equal(completed.completedAt !== undefined, true);
|
||||
});
|
||||
|
||||
test("codex app server health distinguishes available, degraded, and unavailable", async () => {
|
||||
await setup();
|
||||
assert.equal(data.resolveCodexAppServerHealth(undefined), "unavailable");
|
||||
|
||||
@@ -156,3 +156,59 @@ test("local agent infrastructure failures stay out of master agent chat", async
|
||||
"expected the operational log to remain available outside chat",
|
||||
);
|
||||
});
|
||||
|
||||
test("读取已有状态时会把历史 Codex App Server 错误码转成人类可读说明", async () => {
|
||||
await setup();
|
||||
|
||||
const state = await readState();
|
||||
state.projects.push({
|
||||
id: "project-runtime-error-redaction",
|
||||
name: "juyuwan",
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: ["mac-studio"],
|
||||
preview: "juyuwan 执行失败:CODEX_APP_SERVER_TURN_INTERRUPTED",
|
||||
updatedAt: "2026-06-07T14:20:00+08:00",
|
||||
lastMessageAt: "2026-06-07T14:20:00+08:00",
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "project-runtime-error-redaction",
|
||||
threadId: "thread-runtime-error-redaction",
|
||||
threadDisplayName: "juyuwan",
|
||||
folderName: "juyuwan",
|
||||
activityIconCount: 0,
|
||||
updatedAt: "2026-06-07T14:20:00+08:00",
|
||||
codexThreadRef: "019e9b84-decc-7510-b84f-57c5a27de0e3",
|
||||
codexFolderRef: "juyuwan",
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 1,
|
||||
riskLevel: "low",
|
||||
messages: [
|
||||
{
|
||||
id: "msg-runtime-error-redaction",
|
||||
sender: "ops",
|
||||
senderLabel: "juyuwan",
|
||||
body: "juyuwan 执行失败:CODEX_APP_SERVER_TURN_INTERRUPTED",
|
||||
sentAt: "2026-06-07T14:20:00+08:00",
|
||||
kind: "text",
|
||||
},
|
||||
],
|
||||
goals: [],
|
||||
versions: [],
|
||||
});
|
||||
await writeState(state);
|
||||
|
||||
const nextState = await readState();
|
||||
const project = nextState.projects.find((item) => item.id === "project-runtime-error-redaction");
|
||||
assert.ok(project, "expected a reloaded project");
|
||||
const message = project.messages.find((item) => item.id === "msg-runtime-error-redaction");
|
||||
assert.ok(message, "expected the historical message to remain");
|
||||
assert.equal(message.body.includes("CODEX_APP_SERVER_TURN_INTERRUPTED"), false);
|
||||
assert.equal(project.preview.includes("CODEX_APP_SERVER_TURN_INTERRUPTED"), false);
|
||||
assert.match(message.body, /Codex 桌面线程本轮被中断/);
|
||||
assert.match(project.preview, /Codex 桌面线程本轮被中断/);
|
||||
});
|
||||
|
||||
@@ -1592,6 +1592,123 @@ test("POST /api/v1/master-agent/tasks/[taskId]/complete folds thread commentary
|
||||
assert.equal(updatedProject?.unreadCount, 1);
|
||||
});
|
||||
|
||||
test("POST /api/v1/master-agent/tasks/[taskId]/complete strips already mirrored process text from aggregate thread replies", 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 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");
|
||||
|
||||
const processOne = "我先按非侵入方式收口:不碰原项目代码,只把这次 APP 端问题整理成可复现、可提交的诊断文档。";
|
||||
const processTwo = "我在按调试流程收证据,但会收在文档里,不会动任何现有项目文件。";
|
||||
const processThree = "文档已经落下来了,我再做一次范围确认,确保只有独立报告被新增。";
|
||||
const finalText = "我按非侵入方式处理了,没有碰任何原有项目代码,只新增了一份独立排查文档。";
|
||||
const aggregateReply = `${processOne}${processTwo}${processThree}${finalText}`;
|
||||
const requestedAtMs = Date.parse(task.requestedAt);
|
||||
assert.ok(Number.isFinite(requestedAtMs), "expected task requestedAt to be parseable");
|
||||
const taskRelativeTime = (offsetMs: number) => new Date(requestedAtMs + offsetMs).toISOString();
|
||||
|
||||
const mirroredState = await readState();
|
||||
const project = mirroredState.projects.find((item) => item.id === singleProject.id);
|
||||
assert.ok(project, "expected the single-thread project to exist");
|
||||
project.messages = project.messages.filter(
|
||||
(message) =>
|
||||
message.id === task.requestMessageId ||
|
||||
message.executionProgress?.taskId === task.taskId,
|
||||
);
|
||||
project.messages.push(
|
||||
{
|
||||
id: "msg-process-one",
|
||||
sender: "device",
|
||||
senderLabel: project.threadMeta.threadDisplayName,
|
||||
body: processOne,
|
||||
sentAt: taskRelativeTime(1_000),
|
||||
kind: "thread_process",
|
||||
},
|
||||
{
|
||||
id: "msg-process-two",
|
||||
sender: "device",
|
||||
senderLabel: project.threadMeta.threadDisplayName,
|
||||
body: processTwo,
|
||||
sentAt: taskRelativeTime(2_000),
|
||||
kind: "thread_process",
|
||||
},
|
||||
{
|
||||
id: "msg-process-three",
|
||||
sender: "device",
|
||||
senderLabel: project.threadMeta.threadDisplayName,
|
||||
body: processThree,
|
||||
sentAt: taskRelativeTime(3_000),
|
||||
kind: "thread_process",
|
||||
},
|
||||
{
|
||||
id: "msg-final-mirrored",
|
||||
sender: "device",
|
||||
senderLabel: project.threadMeta.threadDisplayName,
|
||||
body: finalText,
|
||||
sentAt: taskRelativeTime(4_000),
|
||||
kind: "text",
|
||||
},
|
||||
);
|
||||
project.preview = finalText;
|
||||
project.lastMessageAt = taskRelativeTime(4_000);
|
||||
project.unreadCount = 1;
|
||||
await writeState(mirroredState);
|
||||
|
||||
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: aggregateReply,
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ taskId: task.taskId }) },
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const nextState = await readState();
|
||||
const updatedProject = nextState.projects.find((item) => item.id === singleProject.id);
|
||||
const aggregateMessages =
|
||||
updatedProject?.messages.filter((message) => message.body === aggregateReply) ?? [];
|
||||
const finalMessages = updatedProject?.messages.filter((message) => message.body === finalText) ?? [];
|
||||
|
||||
assert.equal(aggregateMessages.length, 0, "aggregate process+final reply should not be displayed");
|
||||
assert.equal(finalMessages.length, 1, "already mirrored final result should not be duplicated");
|
||||
assert.equal(updatedProject?.preview, finalText);
|
||||
assert.equal(updatedProject?.unreadCount, 1);
|
||||
|
||||
const cleanupState = await readState();
|
||||
const cleanupProject = cleanupState.projects.find((item) => item.id === singleProject.id);
|
||||
if (cleanupProject) {
|
||||
cleanupProject.messages = [];
|
||||
cleanupProject.preview = "测试线程等待继续处理。";
|
||||
cleanupProject.lastMessageAt = "2026-04-04T11:30:00+08:00";
|
||||
cleanupProject.unreadCount = 0;
|
||||
}
|
||||
await writeState(cleanupState);
|
||||
});
|
||||
|
||||
test("POST /api/v1/master-agent/tasks/[taskId]/complete keeps compact numbered progress updates folded", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
@@ -1649,7 +1766,7 @@ test("POST /api/v1/master-agent/tasks/[taskId]/complete keeps compact numbered p
|
||||
assert.equal(updatedProject?.unreadCount, 0);
|
||||
});
|
||||
|
||||
test("device heartbeat keeps conversation preview on the latest non-process message", async () => {
|
||||
test("device heartbeat activity does not overwrite conversation preview with desktop process text", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
assert.ok(singleProject, "expected a seeded single-thread project");
|
||||
@@ -1713,12 +1830,14 @@ test("device heartbeat keeps conversation preview on the latest non-process mess
|
||||
(message) => message.externalMessageId === "codex-thread:preview-keep:2026-04-24T05:41:14.246Z:p1",
|
||||
);
|
||||
|
||||
assert.equal(processMessage?.kind, "thread_process");
|
||||
assert.equal(processMessage, undefined);
|
||||
assert.equal(updatedProject?.messages.length, 1);
|
||||
assert.equal(updatedProject?.preview, "这是上一轮最终结果。");
|
||||
assert.equal(updatedProject?.unreadCount, 0);
|
||||
assert.equal(updatedProject?.threadMeta.lastObservedCodexActivityAt, "2026-04-24T05:41:14.246Z");
|
||||
});
|
||||
|
||||
test("device heartbeat keeps conversation preview blank when only process messages are mirrored", async () => {
|
||||
test("device heartbeat activity clears stale process preview without appending desktop process text", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
assert.ok(singleProject, "expected a seeded single-thread project");
|
||||
@@ -1773,9 +1892,11 @@ test("device heartbeat keeps conversation preview blank when only process messag
|
||||
(message) => message.externalMessageId === "codex-thread:preview-empty:2026-04-24T05:41:14.246Z:p1",
|
||||
);
|
||||
|
||||
assert.equal(processMessage?.kind, "thread_process");
|
||||
assert.equal(processMessage, undefined);
|
||||
assert.equal(updatedProject?.messages.length, 0);
|
||||
assert.equal(updatedProject?.preview, "");
|
||||
assert.equal(updatedProject?.unreadCount, 0);
|
||||
assert.equal(updatedProject?.threadMeta.lastObservedCodexActivityAt, "2026-04-24T05:41:14.246Z");
|
||||
});
|
||||
|
||||
test("legacy device process text is reclassified and no longer pollutes preview or unread", async () => {
|
||||
|
||||
@@ -12,6 +12,14 @@ let handleTelegramWebhookRequest: (typeof import("../src/lib/telegram-gateway"))
|
||||
let completeTaskRoute: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route"))["POST"];
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function waitForCondition(predicate: () => boolean | Promise<boolean>, timeoutMs = 1000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-telegram-gateway-"));
|
||||
@@ -216,11 +224,17 @@ test("Telegram webhook 对需要排队的消息会记录 externalReplyTarget,
|
||||
);
|
||||
|
||||
assert.equal(completeResponse.status, 200);
|
||||
await waitForCondition(() => outboundCalls.length >= 2);
|
||||
assert.equal(outboundCalls.length, 2);
|
||||
assert.match(String((outboundCalls[1]?.body as { text: string }).text), /已经整理好迁移方案/);
|
||||
|
||||
const completedState = await readState();
|
||||
const completedTask = completedState.masterAgentTasks.find((item) => item.taskId === task?.taskId);
|
||||
let completedState = await readState();
|
||||
let completedTask = completedState.masterAgentTasks.find((item) => item.taskId === task?.taskId);
|
||||
await waitForCondition(async () => {
|
||||
completedState = await readState();
|
||||
completedTask = completedState.masterAgentTasks.find((item) => item.taskId === task?.taskId);
|
||||
return completedTask?.externalReplyTarget?.deliveredAt?.includes("T") === true;
|
||||
});
|
||||
assert.equal(completedTask?.externalReplyTarget?.deliveredAt?.includes("T"), true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
Reference in New Issue
Block a user