169 lines
5.6 KiB
TypeScript
169 lines
5.6 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 subscribeBossEvents: (typeof import("../src/lib/boss-events"))["subscribeBossEvents"];
|
|
|
|
async function setup() {
|
|
if (runtimeRoot) return;
|
|
|
|
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-heartbeat-noise-"));
|
|
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
|
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
|
|
|
const [data, events] = await Promise.all([
|
|
import("../src/lib/boss-data.ts"),
|
|
import("../src/lib/boss-events.ts"),
|
|
]);
|
|
readState = data.readState;
|
|
upsertDeviceHeartbeat = data.upsertDeviceHeartbeat;
|
|
subscribeBossEvents = events.subscribeBossEvents;
|
|
}
|
|
|
|
test.after(async () => {
|
|
if (runtimeRoot) {
|
|
await rm(runtimeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function buildHeartbeatPayload(deviceId: string, projectCandidates: Array<{
|
|
folderName: string;
|
|
threadId: string;
|
|
threadDisplayName: string;
|
|
codexFolderRef: string;
|
|
codexThreadRef: string;
|
|
lastActiveAt: string;
|
|
recentAssistantMessages?: Array<{
|
|
messageId: string;
|
|
body: string;
|
|
sentAt: string;
|
|
phase?: "commentary" | "final_answer";
|
|
}>;
|
|
}>) {
|
|
return {
|
|
deviceId,
|
|
token: `${deviceId}-token`,
|
|
name: "Mac Studio",
|
|
avatar: "M",
|
|
account: "krisolo",
|
|
status: "online" as const,
|
|
quota5h: 68,
|
|
quota7d: 81,
|
|
projects: [],
|
|
endpoint: "mac://kris.local",
|
|
projectCandidates,
|
|
};
|
|
}
|
|
|
|
const bossThreadCandidate = {
|
|
folderName: "boss",
|
|
threadId: "thread-boss-main",
|
|
threadDisplayName: "Boss开发主线程",
|
|
codexFolderRef: "/Users/kris/code/boss",
|
|
codexThreadRef: "thread-boss-main",
|
|
lastActiveAt: "2026-04-10T10:00:00.000Z",
|
|
};
|
|
|
|
test("unchanged device heartbeats do not publish conversation refresh events", async () => {
|
|
await setup();
|
|
|
|
const heartbeat = buildHeartbeatPayload("noise-device-a", [bossThreadCandidate]);
|
|
await upsertDeviceHeartbeat(heartbeat);
|
|
await upsertDeviceHeartbeat(heartbeat);
|
|
|
|
const primedState = await readState();
|
|
const primedDraft = primedState.deviceImportDrafts.find((draft) => draft.deviceId === "noise-device-a");
|
|
assert.equal(primedDraft?.status, "applied");
|
|
|
|
const events: Array<{ event: string; payload: { note?: string; deviceId?: string } }> = [];
|
|
const unsubscribe = subscribeBossEvents((event, payload) => {
|
|
events.push({ event, payload });
|
|
});
|
|
await upsertDeviceHeartbeat(heartbeat);
|
|
unsubscribe();
|
|
|
|
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();
|
|
|
|
const nextThreadCandidate = {
|
|
folderName: "boss",
|
|
threadId: "thread-boss-review",
|
|
threadDisplayName: "Boss回归线程",
|
|
codexFolderRef: "/Users/kris/code/boss",
|
|
codexThreadRef: "thread-boss-review",
|
|
lastActiveAt: "2026-04-10T10:01:00.000Z",
|
|
};
|
|
|
|
await upsertDeviceHeartbeat(buildHeartbeatPayload("noise-device-b", [bossThreadCandidate]));
|
|
await upsertDeviceHeartbeat(buildHeartbeatPayload("noise-device-b", [bossThreadCandidate]));
|
|
|
|
const events: Array<{ event: string; payload: { note?: string; deviceId?: string } }> = [];
|
|
const unsubscribe = subscribeBossEvents((event, payload) => {
|
|
events.push({ event, payload });
|
|
});
|
|
await upsertDeviceHeartbeat(buildHeartbeatPayload("noise-device-b", [bossThreadCandidate, nextThreadCandidate]));
|
|
unsubscribe();
|
|
|
|
assert.deepEqual(
|
|
events.map((event) => [event.event, event.payload.deviceId, event.payload.note]),
|
|
[
|
|
["devices.updated", "noise-device-b", undefined],
|
|
["conversation.updated", "noise-device-b", "device_import.updated"],
|
|
],
|
|
);
|
|
});
|