523 lines
17 KiB
TypeScript
523 lines
17 KiB
TypeScript
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 readState: (typeof import("../src/lib/boss-data"))["readState"];
|
|
let upsertDeviceHeartbeat: (typeof import("../src/lib/boss-data"))["upsertDeviceHeartbeat"];
|
|
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
|
|
|
async function setup() {
|
|
if (runtimeRoot) return;
|
|
|
|
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-heartbeat-message-sync-"));
|
|
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
|
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
|
|
|
const data = await import("../src/lib/boss-data.ts");
|
|
readState = data.readState;
|
|
upsertDeviceHeartbeat = data.upsertDeviceHeartbeat;
|
|
writeState = data.writeState;
|
|
}
|
|
|
|
test.after(async () => {
|
|
if (runtimeRoot) {
|
|
await rm(runtimeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("device heartbeat mirrors recent codex desktop replies into the matching thread conversation once", async () => {
|
|
await setup();
|
|
|
|
const seedHeartbeat = {
|
|
deviceId: "device-message-sync",
|
|
token: "device-message-sync-token",
|
|
name: "Mac Studio",
|
|
avatar: "M",
|
|
account: "krisolo",
|
|
status: "online" as const,
|
|
quota5h: 76,
|
|
quota7d: 85,
|
|
projects: [],
|
|
endpoint: "mac://kris.local",
|
|
projectCandidates: [
|
|
{
|
|
folderName: "boss",
|
|
folderRef: "/Users/kris/code/boss",
|
|
threadId: "thread-boss-main",
|
|
threadDisplayName: "Boss开发主线程",
|
|
codexFolderRef: "/Users/kris/code/boss",
|
|
codexThreadRef: "thread-boss-main",
|
|
lastActiveAt: "2026-04-20T10:00:00.000Z",
|
|
suggestedImport: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
await upsertDeviceHeartbeat(seedHeartbeat);
|
|
await upsertDeviceHeartbeat(seedHeartbeat);
|
|
|
|
const initialState = await readState();
|
|
const importedProject = initialState.projects.find(
|
|
(project) => project.threadMeta.codexThreadRef === "thread-boss-main",
|
|
);
|
|
assert.ok(importedProject, "expected heartbeat auto-import to create the thread conversation");
|
|
|
|
await upsertDeviceHeartbeat({
|
|
...seedHeartbeat,
|
|
projectCandidates: [
|
|
{
|
|
...seedHeartbeat.projectCandidates[0],
|
|
recentAssistantMessages: [
|
|
{
|
|
messageId: "codex-thread:thread-boss-main:2026-04-20T10:02:10.000Z:reply-1",
|
|
body: "桌面 Codex 已经把会话实时同步链路修好了。",
|
|
sentAt: "2026-04-20T10:02:10.000Z",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
let nextState = await readState();
|
|
let nextProject = nextState.projects.find((project) => project.id === importedProject?.id);
|
|
const mirroredMessage = nextProject?.messages.find(
|
|
(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);
|
|
|
|
await upsertDeviceHeartbeat({
|
|
...seedHeartbeat,
|
|
projectCandidates: [
|
|
{
|
|
...seedHeartbeat.projectCandidates[0],
|
|
recentAssistantMessages: [
|
|
{
|
|
messageId: "codex-thread:thread-boss-main:2026-04-20T10:02:10.000Z:reply-1",
|
|
body: "桌面 Codex 已经把会话实时同步链路修好了。",
|
|
sentAt: "2026-04-20T10:02:10.000Z",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
nextState = await readState();
|
|
nextProject = nextState.projects.find((project) => project.id === importedProject?.id);
|
|
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);
|
|
});
|
|
|
|
test("device heartbeat does not duplicate a reply already written by task completion", async () => {
|
|
await setup();
|
|
|
|
const seedHeartbeat = {
|
|
deviceId: "device-message-completion-dedupe",
|
|
token: "device-message-completion-dedupe-token",
|
|
name: "Mac Studio",
|
|
avatar: "M",
|
|
account: "krisolo",
|
|
status: "online" as const,
|
|
quota5h: 76,
|
|
quota7d: 85,
|
|
projects: [],
|
|
endpoint: "mac://kris.local",
|
|
projectCandidates: [
|
|
{
|
|
folderName: "boss",
|
|
folderRef: "/Users/kris/code/boss",
|
|
threadId: "thread-boss-completion-dedupe",
|
|
threadDisplayName: "Boss开发主线程",
|
|
codexFolderRef: "/Users/kris/code/boss",
|
|
codexThreadRef: "thread-boss-completion-dedupe",
|
|
lastActiveAt: "2026-04-20T10:00:00.000Z",
|
|
suggestedImport: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
await upsertDeviceHeartbeat(seedHeartbeat);
|
|
await upsertDeviceHeartbeat(seedHeartbeat);
|
|
const initialState = await readState();
|
|
const importedProject = initialState.projects.find(
|
|
(project) => project.threadMeta.codexThreadRef === "thread-boss-completion-dedupe",
|
|
);
|
|
assert.ok(importedProject);
|
|
|
|
const directReplyBody = "BOSS回归APP消息已收到。";
|
|
const targetProject = initialState.projects.find((project) => project.id === importedProject?.id);
|
|
assert.ok(targetProject);
|
|
targetProject!.messages = [
|
|
{
|
|
id: "msg-direct-completion",
|
|
sender: "device",
|
|
senderLabel: "Boss开发主线程",
|
|
body: directReplyBody,
|
|
sentAt: "2026-04-20T10:02:10.000Z",
|
|
kind: "text",
|
|
},
|
|
];
|
|
targetProject!.preview = directReplyBody;
|
|
targetProject!.lastMessageAt = "2026-04-20T10:02:10.000Z";
|
|
targetProject!.unreadCount = 1;
|
|
await writeState(initialState);
|
|
|
|
await upsertDeviceHeartbeat({
|
|
...seedHeartbeat,
|
|
projectCandidates: [
|
|
{
|
|
...seedHeartbeat.projectCandidates[0],
|
|
recentAssistantMessages: [
|
|
{
|
|
messageId: "codex-thread:thread-boss-completion-dedupe:2026-04-20T10:02:10.001Z:reply-1",
|
|
body: directReplyBody,
|
|
sentAt: "2026-04-20T10:02:10.001Z",
|
|
phase: "final_answer",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const nextState = await readState();
|
|
const nextProject = nextState.projects.find((project) => project.id === importedProject?.id);
|
|
const matchingReplies = nextProject?.messages.filter((message) => message.body === directReplyBody);
|
|
|
|
assert.equal(matchingReplies?.length, 1);
|
|
assert.equal(matchingReplies?.[0]?.externalMessageId, undefined);
|
|
assert.equal(nextProject?.unreadCount, 1);
|
|
assert.equal(nextProject?.preview, directReplyBody);
|
|
});
|
|
|
|
test("device heartbeat does not duplicate a takeover reply already written by master agent", async () => {
|
|
await setup();
|
|
|
|
const seedHeartbeat = {
|
|
deviceId: "device-message-master-dedupe",
|
|
token: "device-message-master-dedupe-token",
|
|
name: "Mac Studio",
|
|
avatar: "M",
|
|
account: "krisolo",
|
|
status: "online" as const,
|
|
quota5h: 76,
|
|
quota7d: 85,
|
|
projects: [],
|
|
endpoint: "mac://kris.local",
|
|
projectCandidates: [
|
|
{
|
|
folderName: "boss",
|
|
folderRef: "/Users/kris/code/boss",
|
|
threadId: "thread-boss-master-dedupe",
|
|
threadDisplayName: "Boss开发主线程",
|
|
codexFolderRef: "/Users/kris/code/boss",
|
|
codexThreadRef: "thread-boss-master-dedupe",
|
|
lastActiveAt: "2026-04-20T10:00:00.000Z",
|
|
suggestedImport: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
await upsertDeviceHeartbeat(seedHeartbeat);
|
|
await upsertDeviceHeartbeat(seedHeartbeat);
|
|
const initialState = await readState();
|
|
const importedProject = initialState.projects.find(
|
|
(project) => project.threadMeta.codexThreadRef === "thread-boss-master-dedupe",
|
|
);
|
|
assert.ok(importedProject);
|
|
|
|
const replyBody = "主Agent托管链路已收到。";
|
|
const targetProject = initialState.projects.find((project) => project.id === importedProject?.id);
|
|
assert.ok(targetProject);
|
|
targetProject!.messages = [
|
|
{
|
|
id: "msg-master-completion",
|
|
sender: "master",
|
|
senderLabel: "主 Agent",
|
|
body: replyBody,
|
|
sentAt: "2026-04-20T10:02:10.000Z",
|
|
kind: "text",
|
|
},
|
|
];
|
|
targetProject!.preview = replyBody;
|
|
targetProject!.lastMessageAt = "2026-04-20T10:02:10.000Z";
|
|
targetProject!.unreadCount = 1;
|
|
await writeState(initialState);
|
|
|
|
await upsertDeviceHeartbeat({
|
|
...seedHeartbeat,
|
|
projectCandidates: [
|
|
{
|
|
...seedHeartbeat.projectCandidates[0],
|
|
recentAssistantMessages: [
|
|
{
|
|
messageId: "codex-thread:thread-boss-master-dedupe:2026-04-20T10:02:10.001Z:reply-1",
|
|
body: replyBody,
|
|
sentAt: "2026-04-20T10:02:10.001Z",
|
|
phase: "final_answer",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const nextState = await readState();
|
|
const nextProject = nextState.projects.find((project) => project.id === importedProject?.id);
|
|
const matchingReplies = nextProject?.messages.filter((message) => message.body === replyBody);
|
|
|
|
assert.equal(matchingReplies?.length, 1);
|
|
assert.equal(matchingReplies?.[0]?.sender, "master");
|
|
assert.equal(matchingReplies?.[0]?.externalMessageId, undefined);
|
|
assert.equal(nextProject?.unreadCount, 1);
|
|
assert.equal(nextProject?.preview, replyBody);
|
|
});
|
|
|
|
test("device heartbeat does not count commentary replies as unread and keeps only the final result unread", async () => {
|
|
await setup();
|
|
|
|
const seedHeartbeat = {
|
|
deviceId: "device-message-phase",
|
|
token: "device-message-phase-token",
|
|
name: "Mac Studio",
|
|
avatar: "M",
|
|
account: "krisolo",
|
|
status: "online" as const,
|
|
quota5h: 76,
|
|
quota7d: 85,
|
|
projects: [],
|
|
endpoint: "mac://kris.local",
|
|
projectCandidates: [
|
|
{
|
|
folderName: "boss",
|
|
folderRef: "/Users/kris/code/boss",
|
|
threadId: "thread-boss-phase",
|
|
threadDisplayName: "Boss开发主线程",
|
|
codexFolderRef: "/Users/kris/code/boss",
|
|
codexThreadRef: "thread-boss-phase",
|
|
lastActiveAt: "2026-04-20T10:00:00.000Z",
|
|
suggestedImport: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
await upsertDeviceHeartbeat(seedHeartbeat);
|
|
await upsertDeviceHeartbeat(seedHeartbeat);
|
|
|
|
await upsertDeviceHeartbeat({
|
|
...seedHeartbeat,
|
|
projectCandidates: [
|
|
{
|
|
...seedHeartbeat.projectCandidates[0],
|
|
recentAssistantMessages: [
|
|
{
|
|
messageId: "codex-thread:thread-boss-phase:2026-04-20T10:03:00.000Z:commentary-1",
|
|
body: "我先检查聊天折叠链路,确认过程消息不会直接展开。",
|
|
sentAt: "2026-04-20T10:03:00.000Z",
|
|
phase: "commentary",
|
|
},
|
|
{
|
|
messageId: "codex-thread:thread-boss-phase:2026-04-20T10:05:00.000Z:final-1",
|
|
body: "这轮已经完成折叠修复,未读现在只会算最终结果。",
|
|
sentAt: "2026-04-20T10:05:00.000Z",
|
|
phase: "final_answer",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const nextState = await readState();
|
|
const nextProject = nextState.projects.find(
|
|
(project) => project.threadMeta.codexThreadRef === "thread-boss-phase",
|
|
);
|
|
const processMessage = nextProject?.messages.find(
|
|
(message) =>
|
|
message.externalMessageId === "codex-thread:thread-boss-phase:2026-04-20T10:03:00.000Z:commentary-1",
|
|
);
|
|
const finalMessage = nextProject?.messages.find(
|
|
(message) =>
|
|
message.externalMessageId === "codex-thread:thread-boss-phase:2026-04-20T10:05:00.000Z:final-1",
|
|
);
|
|
|
|
assert.ok(nextProject);
|
|
assert.equal(processMessage?.kind, "thread_process");
|
|
assert.equal(finalMessage?.kind, "text");
|
|
assert.equal(nextProject?.preview, "这轮已经完成折叠修复,未读现在只会算最终结果。");
|
|
assert.equal(nextProject?.unreadCount, 1);
|
|
});
|
|
|
|
test("device heartbeat does not replay old desktop replies after conversation history is cleared", async () => {
|
|
await setup();
|
|
|
|
const seedHeartbeat = {
|
|
deviceId: "device-message-reset",
|
|
token: "device-message-reset-token",
|
|
name: "Mac Studio",
|
|
avatar: "M",
|
|
account: "krisolo",
|
|
status: "online" as const,
|
|
quota5h: 76,
|
|
quota7d: 85,
|
|
projects: [],
|
|
endpoint: "mac://kris.local",
|
|
projectCandidates: [
|
|
{
|
|
folderName: "boss",
|
|
folderRef: "/Users/kris/code/boss",
|
|
threadId: "thread-boss-reset",
|
|
threadDisplayName: "Boss开发主线程",
|
|
codexFolderRef: "/Users/kris/code/boss",
|
|
codexThreadRef: "thread-boss-reset",
|
|
lastActiveAt: "2026-04-20T10:00:00.000Z",
|
|
suggestedImport: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
await upsertDeviceHeartbeat(seedHeartbeat);
|
|
await upsertDeviceHeartbeat(seedHeartbeat);
|
|
|
|
const initialState = await readState();
|
|
const importedProject = initialState.projects.find(
|
|
(project) => project.threadMeta.codexThreadRef === "thread-boss-reset",
|
|
);
|
|
assert.ok(importedProject);
|
|
|
|
initialState.conversationHistoryClearedAt = "2026-04-20T10:10:00.000Z";
|
|
const targetProject = initialState.projects.find((project) => project.id === importedProject?.id);
|
|
assert.ok(targetProject);
|
|
targetProject!.messages = [];
|
|
targetProject!.preview = "";
|
|
targetProject!.unreadCount = 0;
|
|
await writeState(initialState);
|
|
|
|
await upsertDeviceHeartbeat({
|
|
...seedHeartbeat,
|
|
projectCandidates: [
|
|
{
|
|
...seedHeartbeat.projectCandidates[0],
|
|
lastActiveAt: "2026-04-20T10:12:00.000Z",
|
|
recentAssistantMessages: [
|
|
{
|
|
messageId: "codex-thread:thread-boss-reset:2026-04-20T10:05:00.000Z:old-final",
|
|
body: "这条旧回复不应该在清空历史后被重新导回。",
|
|
sentAt: "2026-04-20T10:05:00.000Z",
|
|
phase: "final_answer",
|
|
},
|
|
{
|
|
messageId: "codex-thread:thread-boss-reset:2026-04-20T10:11:00.000Z:new-final",
|
|
body: "这条新回复应该继续同步回来。",
|
|
sentAt: "2026-04-20T10:11:00.000Z",
|
|
phase: "final_answer",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const nextState = await readState();
|
|
const nextProject = nextState.projects.find((project) => project.id === importedProject?.id);
|
|
assert.ok(nextProject);
|
|
assert.equal(
|
|
nextProject?.messages.some(
|
|
(message) =>
|
|
message.externalMessageId === "codex-thread:thread-boss-reset:2026-04-20T10:05:00.000Z:old-final",
|
|
),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
nextProject?.messages.some(
|
|
(message) =>
|
|
message.externalMessageId === "codex-thread:thread-boss-reset:2026-04-20T10:11:00.000Z:new-final",
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(nextProject?.preview, "这条新回复应该继续同步回来。");
|
|
assert.equal(nextProject?.unreadCount, 1);
|
|
});
|
|
|
|
test("device heartbeat legacy process text is normalized to thread_process and does not become preview", async () => {
|
|
await setup();
|
|
|
|
const seedHeartbeat = {
|
|
deviceId: "device-message-legacy-process",
|
|
token: "device-message-legacy-process-token",
|
|
name: "Mac Studio",
|
|
avatar: "M",
|
|
account: "krisolo",
|
|
status: "online" as const,
|
|
quota5h: 76,
|
|
quota7d: 85,
|
|
projects: [],
|
|
endpoint: "mac://kris.local",
|
|
projectCandidates: [
|
|
{
|
|
folderName: "boss",
|
|
folderRef: "/Users/kris/code/boss",
|
|
threadId: "thread-boss-legacy-process",
|
|
threadDisplayName: "Boss开发主线程",
|
|
codexFolderRef: "/Users/kris/code/boss",
|
|
codexThreadRef: "thread-boss-legacy-process",
|
|
lastActiveAt: "2026-04-20T10:00:00.000Z",
|
|
suggestedImport: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
await upsertDeviceHeartbeat(seedHeartbeat);
|
|
await upsertDeviceHeartbeat(seedHeartbeat);
|
|
|
|
const resetState = await readState();
|
|
resetState.conversationHistoryClearedAt = undefined;
|
|
await writeState(resetState);
|
|
|
|
await upsertDeviceHeartbeat({
|
|
...seedHeartbeat,
|
|
projectCandidates: [
|
|
{
|
|
...seedHeartbeat.projectCandidates[0],
|
|
recentAssistantMessages: [
|
|
{
|
|
messageId: "codex-thread:thread-boss-legacy-process:2026-04-20T10:03:00.000Z:commentary-legacy",
|
|
body: "我继续把这条链路又往下收了一层,补的是“历史脏消息”的兼容,不只是新消息规则。",
|
|
sentAt: "2026-04-20T10:03:00.000Z",
|
|
phase: "commentary",
|
|
},
|
|
{
|
|
messageId: "codex-thread:thread-boss-legacy-process:2026-04-20T10:05:00.000Z:final-1",
|
|
body: "这轮已经完成折叠修复,未读现在只会算最终结果。",
|
|
sentAt: "2026-04-20T10:05:00.000Z",
|
|
phase: "final_answer",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const nextState = await readState();
|
|
const nextProject = nextState.projects.find(
|
|
(project) => project.threadMeta.codexThreadRef === "thread-boss-legacy-process",
|
|
);
|
|
const legacyProcessMessage = nextProject?.messages.find(
|
|
(message) =>
|
|
message.externalMessageId ===
|
|
"codex-thread:thread-boss-legacy-process:2026-04-20T10:03:00.000Z:commentary-legacy",
|
|
);
|
|
|
|
assert.ok(nextProject);
|
|
assert.equal(legacyProcessMessage?.kind, "thread_process");
|
|
assert.equal(nextProject?.preview, "这轮已经完成折叠修复,未读现在只会算最终结果。");
|
|
assert.equal(nextProject?.unreadCount, 1);
|
|
});
|