1443 lines
49 KiB
TypeScript
1443 lines
49 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, writeFile } from "node:fs/promises";
|
||
|
||
let runtimeRoot = "";
|
||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||
let updateConversationAction: (typeof import("../src/lib/boss-data"))["updateConversationAction"];
|
||
let getConversationHomeItems: (typeof import("../src/lib/boss-projections"))["getConversationHomeItems"];
|
||
let getConversationWebItems: (typeof import("../src/lib/boss-projections"))["getConversationWebItems"];
|
||
let getConversationHomeItemForProject: (typeof import("../src/lib/boss-projections"))["getConversationHomeItemForProject"];
|
||
let getConversationThreadItemForProject: (typeof import("../src/lib/boss-projections"))["getConversationThreadItemForProject"];
|
||
let getConversationFolderView: (typeof import("../src/lib/boss-projections"))["getConversationFolderView"];
|
||
let getProjectDetailView: (typeof import("../src/lib/boss-projections"))["getProjectDetailView"];
|
||
let buildProjectMessagesRealtimePayload: (typeof import("../src/lib/boss-projections"))["buildProjectMessagesRealtimePayload"];
|
||
let formatTimestampLabel: (typeof import("../src/lib/boss-projections"))["formatTimestampLabel"];
|
||
let getConversationListItemPresentation: (typeof import("../src/components/app-ui"))["getConversationListItemPresentation"];
|
||
let getConversationActionAvailability: (typeof import("../src/components/app-ui"))["getConversationActionAvailability"];
|
||
let getConversationActionsPath: (typeof import("../src/components/app-ui"))["getConversationActionsPath"];
|
||
let getConversationPinnedBadgeLabel: (typeof import("../src/components/app-ui"))["getConversationPinnedBadgeLabel"];
|
||
let seededStateSnapshot: Awaited<ReturnType<typeof import("../src/lib/boss-data").readState>> | null = null;
|
||
|
||
async function setup() {
|
||
if (runtimeRoot) return;
|
||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-conversation-home-"));
|
||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||
|
||
const [data, projections, ui] = await Promise.all([
|
||
import("../src/lib/boss-data.ts"),
|
||
import("../src/lib/boss-projections.ts"),
|
||
import("../src/components/app-ui.tsx"),
|
||
]);
|
||
readState = data.readState;
|
||
writeState = data.writeState;
|
||
updateConversationAction = data.updateConversationAction;
|
||
getConversationHomeItems = projections.getConversationHomeItems;
|
||
getConversationWebItems = projections.getConversationWebItems;
|
||
getConversationHomeItemForProject = projections.getConversationHomeItemForProject;
|
||
getConversationThreadItemForProject = projections.getConversationThreadItemForProject;
|
||
getConversationFolderView = projections.getConversationFolderView;
|
||
getProjectDetailView = projections.getProjectDetailView;
|
||
buildProjectMessagesRealtimePayload = projections.buildProjectMessagesRealtimePayload;
|
||
formatTimestampLabel = projections.formatTimestampLabel;
|
||
getConversationListItemPresentation = ui.getConversationListItemPresentation;
|
||
getConversationActionAvailability = ui.getConversationActionAvailability;
|
||
getConversationActionsPath = ui.getConversationActionsPath;
|
||
getConversationPinnedBadgeLabel = ui.getConversationPinnedBadgeLabel;
|
||
seededStateSnapshot = structuredClone(await readState());
|
||
}
|
||
|
||
async function resetSeedState() {
|
||
if (!seededStateSnapshot) {
|
||
throw new Error("seeded state snapshot missing");
|
||
}
|
||
await writeState(structuredClone(seededStateSnapshot));
|
||
}
|
||
|
||
test.after(async () => {
|
||
if (runtimeRoot) {
|
||
await rm(runtimeRoot, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
function buildImportedThreadProject(deviceId: string, id: string, folderName: string, codexFolderRef: string, threadName: string, threadId: string, lastMessageAt: string) {
|
||
return {
|
||
id,
|
||
name: threadName,
|
||
pinned: false,
|
||
systemPinned: false,
|
||
deviceIds: [deviceId],
|
||
preview: `最近消息:${threadName}`,
|
||
updatedAt: lastMessageAt,
|
||
lastMessageAt,
|
||
isGroup: false,
|
||
threadMeta: {
|
||
projectId: id,
|
||
threadId,
|
||
threadDisplayName: threadName,
|
||
folderName,
|
||
activityIconCount: 1,
|
||
updatedAt: lastMessageAt,
|
||
codexFolderRef,
|
||
codexThreadRef: threadId,
|
||
},
|
||
groupMembers: [],
|
||
createdByAgent: true,
|
||
collaborationMode: "development" as const,
|
||
approvalState: "not_required" as const,
|
||
unreadCount: 0,
|
||
riskLevel: "low" as const,
|
||
messages: [],
|
||
goals: [],
|
||
versions: [],
|
||
};
|
||
}
|
||
|
||
function buildThreadContextSnapshot(
|
||
projectId: string,
|
||
threadId: string,
|
||
title: string,
|
||
contextBudgetLevel: "safe" | "watch" | "urgent" | "critical",
|
||
mustFinishBeforeCompaction: boolean,
|
||
contextBudgetRemainingPct: number,
|
||
capturedAt: string,
|
||
nodeId: string,
|
||
) {
|
||
return {
|
||
snapshotId: `${projectId}-${threadId}-snapshot`,
|
||
projectId,
|
||
taskId: `${projectId}-${threadId}-task`,
|
||
threadId,
|
||
title,
|
||
summary: `${title} 的线程状态`,
|
||
nodeId,
|
||
workerId: "worker-1",
|
||
sourceKind: "worker_estimator",
|
||
status: "context_urgent",
|
||
contextBudgetRemainingPct,
|
||
contextBudgetLevel,
|
||
mustFinishBeforeCompaction,
|
||
estimatedRemainingTurns: 4,
|
||
estimatedRemainingLargeMessages: 2,
|
||
compactionCount: 0,
|
||
patchPending: false,
|
||
testsPending: false,
|
||
evidencePending: false,
|
||
checklist: [],
|
||
capturedAt,
|
||
} satisfies import("../src/lib/boss-data").ThreadContextSnapshot;
|
||
}
|
||
|
||
test("folder archives use the latest thread preview/time while subtitle and context ring come from the right threads", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-latest",
|
||
"Boss",
|
||
"boss",
|
||
"最新线程",
|
||
"thread-latest",
|
||
"2026-04-04T12:00:00+08:00",
|
||
),
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-urgent",
|
||
"Boss",
|
||
"boss",
|
||
"优先收尾",
|
||
"thread-urgent",
|
||
"2026-04-04T11:00:00+08:00",
|
||
),
|
||
);
|
||
state.threadContextSnapshots = [
|
||
buildThreadContextSnapshot(
|
||
"boss-thread-latest",
|
||
"thread-latest",
|
||
"最新线程",
|
||
"critical",
|
||
false,
|
||
12,
|
||
"2026-04-04T12:05:00+08:00",
|
||
"node-latest",
|
||
),
|
||
buildThreadContextSnapshot(
|
||
"boss-thread-urgent",
|
||
"thread-urgent",
|
||
"优先收尾",
|
||
"critical",
|
||
true,
|
||
87,
|
||
"2026-04-04T11:05:00+08:00",
|
||
"node-urgent",
|
||
),
|
||
];
|
||
|
||
const originalNow = Date.now;
|
||
Date.now = () => new Date("2026-04-04T12:30:00+08:00").getTime();
|
||
try {
|
||
const folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive");
|
||
|
||
assert.ok(folder, "expected grouped folder archive item");
|
||
assert.equal(folder?.threadTitle, "Boss");
|
||
assert.equal(folder?.folderLabel, "2 个线程 · 最近:最新线程");
|
||
assert.equal(folder?.preview, "最近消息:最新线程");
|
||
assert.equal(folder?.lastMessagePreview, "最近消息:最新线程");
|
||
assert.equal(folder?.latestReplyAt, "2026-04-04T12:00:00+08:00");
|
||
assert.equal(folder?.latestReplyLabel, formatTimestampLabel("2026-04-04T12:00:00+08:00"));
|
||
assert.equal(folder?.contextBudgetIndicator.level, "critical");
|
||
assert.equal(folder?.contextBudgetIndicator.percent, 87);
|
||
assert.equal(folder?.mustFinishBeforeCompaction, true);
|
||
assert.equal(folder?.contextBudgetSourceNodeId, "node-urgent");
|
||
assert.equal(folder?.contextBudgetUpdatedAt, "2026-04-04T11:05:00+08:00");
|
||
} finally {
|
||
Date.now = originalNow;
|
||
}
|
||
});
|
||
|
||
test("conversation home patch lookup returns the visible folder archive item for grouped threads", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-a",
|
||
"Boss",
|
||
"boss",
|
||
"线程 A",
|
||
"thread-a",
|
||
"2026-04-04T12:00:00+08:00",
|
||
),
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-b",
|
||
"Boss",
|
||
"boss",
|
||
"线程 B",
|
||
"thread-b",
|
||
"2026-04-04T11:00:00+08:00",
|
||
),
|
||
);
|
||
|
||
const item = getConversationHomeItemForProject(state, "boss-thread-b");
|
||
|
||
assert.ok(item, "expected grouped thread lookup to resolve to a visible home item");
|
||
assert.equal(item?.conversationType, "folder_archive");
|
||
assert.equal(item?.projectId, "mac-studio:boss");
|
||
assert.deepEqual(item?.searchTargetProjectIds, ["boss-thread-a", "boss-thread-b"]);
|
||
});
|
||
|
||
test("conversation home patch lookup returns the direct thread item when no folder archive exists", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"solo-thread",
|
||
"Solo",
|
||
"solo",
|
||
"单线程",
|
||
"thread-solo",
|
||
"2026-04-04T12:00:00+08:00",
|
||
),
|
||
);
|
||
|
||
const item = getConversationHomeItemForProject(state, "solo-thread");
|
||
|
||
assert.ok(item, "expected single thread lookup to resolve to its visible home item");
|
||
assert.equal(item?.conversationType, "single_device");
|
||
assert.equal(item?.projectId, "solo-thread");
|
||
});
|
||
|
||
test("conversation thread patch lookup keeps the direct single-thread item for grouped folders", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-a",
|
||
"Boss",
|
||
"boss",
|
||
"线程 A",
|
||
"thread-a",
|
||
"2026-04-04T12:00:00+08:00",
|
||
),
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-b",
|
||
"Boss",
|
||
"boss",
|
||
"线程 B",
|
||
"thread-b",
|
||
"2026-04-04T11:00:00+08:00",
|
||
),
|
||
);
|
||
|
||
const item = getConversationThreadItemForProject(state, "boss-thread-b");
|
||
|
||
assert.ok(item, "expected grouped thread lookup to resolve to its direct thread row");
|
||
assert.equal(item?.conversationType, "single_device");
|
||
assert.equal(item?.projectId, "boss-thread-b");
|
||
assert.equal(item?.folderKey, "mac-studio:boss");
|
||
});
|
||
|
||
test("folder archive context ring prefers more urgent contextBudgetLevel when mustFinishBeforeCompaction is equal", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-latest",
|
||
"Boss",
|
||
"boss",
|
||
"最新线程",
|
||
"thread-latest",
|
||
"2026-04-04T12:00:00+08:00",
|
||
),
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-watch",
|
||
"Boss",
|
||
"boss",
|
||
"次要关注",
|
||
"thread-watch",
|
||
"2026-04-04T11:00:00+08:00",
|
||
),
|
||
);
|
||
state.threadContextSnapshots = [
|
||
buildThreadContextSnapshot(
|
||
"boss-thread-latest",
|
||
"thread-latest",
|
||
"最新线程",
|
||
"watch",
|
||
false,
|
||
22,
|
||
"2026-04-04T12:05:00+08:00",
|
||
"node-watch",
|
||
),
|
||
buildThreadContextSnapshot(
|
||
"boss-thread-watch",
|
||
"thread-watch",
|
||
"次要关注",
|
||
"urgent",
|
||
false,
|
||
61,
|
||
"2026-04-04T11:05:00+08:00",
|
||
"node-urgent",
|
||
),
|
||
];
|
||
|
||
const folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive");
|
||
|
||
assert.ok(folder, "expected grouped folder archive item");
|
||
assert.equal(folder?.contextBudgetIndicator.level, "urgent");
|
||
assert.equal(folder?.contextBudgetIndicator.percent, 61);
|
||
assert.equal(folder?.contextBudgetSourceNodeId, "node-urgent");
|
||
assert.equal(folder?.contextBudgetUpdatedAt, "2026-04-04T11:05:00+08:00");
|
||
});
|
||
|
||
test("folder archive context ring prefers newer latestReplyAt when mustFinishBeforeCompaction and contextBudgetLevel are equal", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-older",
|
||
"Boss",
|
||
"boss",
|
||
"较早线程",
|
||
"thread-older",
|
||
"2026-04-04T10:00:00+08:00",
|
||
),
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-newer",
|
||
"Boss",
|
||
"boss",
|
||
"较新线程",
|
||
"thread-newer",
|
||
"2026-04-04T12:00:00+08:00",
|
||
),
|
||
);
|
||
state.threadContextSnapshots = [
|
||
buildThreadContextSnapshot(
|
||
"boss-thread-older",
|
||
"thread-older",
|
||
"较早线程",
|
||
"urgent",
|
||
false,
|
||
19,
|
||
"2026-04-04T10:05:00+08:00",
|
||
"node-older",
|
||
),
|
||
buildThreadContextSnapshot(
|
||
"boss-thread-newer",
|
||
"thread-newer",
|
||
"较新线程",
|
||
"urgent",
|
||
false,
|
||
19,
|
||
"2026-04-04T12:05:00+08:00",
|
||
"node-newer",
|
||
),
|
||
];
|
||
|
||
const folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive");
|
||
|
||
assert.ok(folder, "expected grouped folder archive item");
|
||
assert.equal(folder?.latestReplyAt, "2026-04-04T12:00:00+08:00");
|
||
assert.equal(folder?.contextBudgetIndicator.level, "urgent");
|
||
assert.equal(folder?.contextBudgetIndicator.percent, 19);
|
||
assert.equal(folder?.contextBudgetSourceNodeId, "node-newer");
|
||
assert.equal(folder?.contextBudgetUpdatedAt, "2026-04-04T12:05:00+08:00");
|
||
});
|
||
|
||
test("conversation home stays visually empty after history is cleared", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.conversationHistoryClearedAt = "2026-04-24T10:00:00.000Z";
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
const masterProject = state.projects.find((project) => project.id === "master-agent");
|
||
assert.ok(masterProject, "expected master-agent project");
|
||
if (masterProject) {
|
||
masterProject.preview = "";
|
||
masterProject.messages = [];
|
||
masterProject.unreadCount = 0;
|
||
}
|
||
state.threadContextSnapshots = [];
|
||
state.opsFaults = [];
|
||
state.auditRequests = [];
|
||
state.auditResults = [];
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-clear-a",
|
||
"Boss",
|
||
"boss",
|
||
"线程 A",
|
||
"thread-clear-a",
|
||
"2026-04-24T09:00:00.000Z",
|
||
),
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-clear-b",
|
||
"Boss",
|
||
"boss",
|
||
"线程 B",
|
||
"thread-clear-b",
|
||
"2026-04-24T08:00:00.000Z",
|
||
),
|
||
);
|
||
for (const project of state.projects) {
|
||
if (project.id !== "master-agent") {
|
||
project.preview = "";
|
||
project.messages = [];
|
||
project.unreadCount = 0;
|
||
}
|
||
}
|
||
|
||
const items = getConversationHomeItems(state);
|
||
const masterItem = items.find((item) => item.projectId === "master-agent");
|
||
const folderItem = items.find((item) => item.conversationType === "folder_archive");
|
||
|
||
assert.ok(masterItem, "expected master-agent home item");
|
||
assert.equal(masterItem?.preview, "");
|
||
assert.equal(masterItem?.lastMessagePreview, "");
|
||
|
||
assert.ok(folderItem, "expected folder archive item");
|
||
assert.equal(folderItem?.preview, "");
|
||
assert.equal(folderItem?.lastMessagePreview, "");
|
||
});
|
||
|
||
test("conversation home hides legacy process-like previews on direct thread rows", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push({
|
||
...buildImportedThreadProject(
|
||
"mac-studio",
|
||
"legacy-process-preview-thread",
|
||
"Boss",
|
||
"boss",
|
||
"Boss开发主线程",
|
||
"thread-legacy-process-preview",
|
||
"2026-04-24T18:30:00+08:00",
|
||
),
|
||
preview: "我继续把这条链路又往下收了一层,补的是“历史脏消息”的兼容,不只是新消息规则。",
|
||
messages: [
|
||
{
|
||
id: "legacy-process-preview-message",
|
||
sender: "device",
|
||
senderLabel: "Boss开发主线程",
|
||
body: "我继续把这条链路又往下收了一层,补的是“历史脏消息”的兼容,不只是新消息规则。",
|
||
sentAt: "2026-04-24T18:30:00+08:00",
|
||
kind: "thread_process",
|
||
},
|
||
],
|
||
unreadCount: 0,
|
||
});
|
||
|
||
const item = getConversationHomeItems(state).find((entry) => entry.projectId === "legacy-process-preview-thread");
|
||
|
||
assert.ok(item, "expected direct thread home item");
|
||
assert.equal(item?.preview, "");
|
||
assert.equal(item?.lastMessagePreview, "");
|
||
});
|
||
|
||
test("conversation home collapses multiline markdown-heavy previews into a short digest", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push({
|
||
...buildImportedThreadProject(
|
||
"mac-studio",
|
||
"markdown-heavy-preview-thread",
|
||
"Boss",
|
||
"boss",
|
||
"Boss开发主线程",
|
||
"thread-markdown-heavy-preview",
|
||
"2026-04-24T18:31:00+08:00",
|
||
),
|
||
preview:
|
||
"这轮我继续把真机阻塞往下压了一层,而且拿到了比 xcodebuild.log 更强的设备侧证据。\n\n" +
|
||
"我先把设备 syslog 伴随采集正式接进了 run_ipad_harness.sh,并先用失败断言锁住在 run-ipad-harness-source.test.ts。\n" +
|
||
"现在每次 harness 真机跑都会自动落盘 device-syslog.log 和 device-syslog-signals.log。",
|
||
messages: [
|
||
{
|
||
id: "markdown-heavy-preview-message",
|
||
sender: "device",
|
||
senderLabel: "Boss开发主线程",
|
||
body:
|
||
"这轮我继续把真机阻塞往下压了一层,而且拿到了比 xcodebuild.log 更强的设备侧证据。\n\n" +
|
||
"我先把设备 syslog 伴随采集正式接进了 run_ipad_harness.sh,并先用失败断言锁住在 run-ipad-harness-source.test.ts。\n" +
|
||
"现在每次 harness 真机跑都会自动落盘 device-syslog.log 和 device-syslog-signals.log。",
|
||
sentAt: "2026-04-24T18:31:00+08:00",
|
||
kind: "text",
|
||
},
|
||
],
|
||
unreadCount: 1,
|
||
});
|
||
|
||
const item = getConversationHomeItems(state).find((entry) => entry.projectId === "markdown-heavy-preview-thread");
|
||
|
||
assert.ok(item, "expected direct thread home item");
|
||
assert.equal(item?.preview.includes("\n"), false);
|
||
assert.equal(item?.lastMessagePreview.includes("\n"), false);
|
||
assert.equal((item?.preview ?? "").length <= 72, true);
|
||
assert.match(item?.preview ?? "", /这轮我继续把真机阻塞往下压了一层/);
|
||
});
|
||
|
||
test("conversation home compacts structured project summary json previews into readable text", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push({
|
||
...buildImportedThreadProject(
|
||
"mac-studio",
|
||
"summary-thread-1",
|
||
"luyinka",
|
||
"/Users/kris/code/luyinka",
|
||
"Android 对齐",
|
||
"thread-1",
|
||
"2026-04-24T12:00:00+08:00",
|
||
),
|
||
preview: JSON.stringify({
|
||
projectGoal: "以安卓成熟版和正式截图为基准完成鸿蒙原生 SHMCI 的页面、录音卡链路与主要功能对齐。",
|
||
currentProgress: "主链路页面已对齐,正在收口录音卡和设备联调。",
|
||
technicalArchitecture: "鸿蒙原生 ArkUI + 音频链路适配。",
|
||
currentBlockers: "",
|
||
recommendedNextStep: "继续真机回归。",
|
||
}),
|
||
lastMessageAt: "2026-04-24T12:00:00+08:00",
|
||
});
|
||
|
||
const item = getConversationHomeItems(state).find((entry) => entry.projectId === "summary-thread-1");
|
||
|
||
assert.ok(item, "expected json preview item");
|
||
assert.equal(item?.preview.startsWith("{"), false);
|
||
assert.match(item?.preview ?? "", /目标:以安卓成熟版和正式截图为基准完成鸿蒙原生 SHMCI/);
|
||
assert.equal((item?.preview ?? "").length <= 73, true);
|
||
});
|
||
|
||
test("conversation home upgrades and downgrades a folder archive as thread count crosses 1 and 2", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
try {
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-1",
|
||
"Boss",
|
||
"boss",
|
||
"归档确认",
|
||
"thread-1",
|
||
"2026-04-04T10:00:00+08:00",
|
||
),
|
||
);
|
||
await writeFile(process.env.BOSS_STATE_FILE as string, `${JSON.stringify(state, null, 2)}\n`);
|
||
|
||
let items = getConversationHomeItems(state);
|
||
let direct = items.find((item) => item.projectId === "boss-thread-1");
|
||
|
||
assert.ok(direct, "expected a single thread to remain direct");
|
||
assert.equal(direct?.conversationType, "single_device");
|
||
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-2",
|
||
"Boss",
|
||
"boss",
|
||
"发布回滚",
|
||
"thread-2",
|
||
"2026-04-04T11:00:00+08:00",
|
||
),
|
||
);
|
||
|
||
items = getConversationHomeItems(state);
|
||
const folder = items.find(
|
||
(item) => item.conversationType === "folder_archive" && item.folderKey === "mac-studio:boss",
|
||
);
|
||
|
||
assert.ok(folder, "expected folder archive once the folder has 2 threads");
|
||
assert.equal(folder?.threadCount, 2);
|
||
assert.equal(items.some((item) => item.projectId === "boss-thread-1"), false);
|
||
assert.equal(items.some((item) => item.projectId === "boss-thread-2"), false);
|
||
|
||
state.projects = state.projects.filter((project) => project.id !== "boss-thread-2");
|
||
|
||
items = getConversationHomeItems(state);
|
||
direct = items.find((item) => item.projectId === "boss-thread-1");
|
||
|
||
assert.ok(direct, "expected a single remaining thread to downgrade back to direct");
|
||
assert.equal(direct?.conversationType, "single_device");
|
||
assert.equal(
|
||
items.some((item) => item.conversationType === "folder_archive" && item.folderKey === "mac-studio:boss"),
|
||
false,
|
||
);
|
||
} finally {
|
||
await resetSeedState();
|
||
}
|
||
});
|
||
|
||
test("folder archive pin state follows child threads and folder toggle syncs all threads", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
{
|
||
...buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-1",
|
||
"Boss",
|
||
"boss",
|
||
"归档确认",
|
||
"thread-1",
|
||
"2026-04-04T10:00:00+08:00",
|
||
),
|
||
pinned: true,
|
||
},
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-2",
|
||
"Boss",
|
||
"boss",
|
||
"发布回滚",
|
||
"thread-2",
|
||
"2026-04-04T11:00:00+08:00",
|
||
),
|
||
);
|
||
|
||
const items = getConversationHomeItems(state);
|
||
let folder = items.find((item) => item.conversationType === "folder_archive" && item.folderKey === "mac-studio:boss");
|
||
|
||
assert.ok(folder, "expected folder archive once the folder has 2 threads");
|
||
assert.equal(folder?.topPinnedLabel, "置顶");
|
||
assert.equal(folder?.manualPinned, true);
|
||
|
||
for (const project of state.projects.filter((project) => project.id.startsWith("boss-thread"))) {
|
||
project.pinned = false;
|
||
}
|
||
folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive" && item.folderKey === "mac-studio:boss");
|
||
assert.ok(folder, "expected folder archive after unpinning folder");
|
||
assert.equal(folder?.topPinnedLabel, undefined);
|
||
assert.equal(folder?.manualPinned, false);
|
||
|
||
for (const project of state.projects.filter((project) => project.id.startsWith("boss-thread"))) {
|
||
project.pinned = true;
|
||
}
|
||
folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive" && item.folderKey === "mac-studio:boss");
|
||
assert.ok(folder, "expected folder archive after restoring folder pin");
|
||
assert.equal(folder?.topPinnedLabel, "置顶");
|
||
assert.equal(folder?.manualPinned, true);
|
||
});
|
||
|
||
test("folder archive toggle_pin updates all threads that share the folder key", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
try {
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"yuandi-thread-1",
|
||
"园地",
|
||
"/Users/kris/code/yuandi",
|
||
"线程一",
|
||
"thread-1",
|
||
"2026-04-05T10:00:00+08:00",
|
||
),
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"yuandi-thread-2",
|
||
"园地",
|
||
"/Users/kris/code/yuandi",
|
||
"线程二",
|
||
"thread-2",
|
||
"2026-04-05T11:00:00+08:00",
|
||
),
|
||
);
|
||
await writeFile(process.env.BOSS_STATE_FILE as string, `${JSON.stringify(state, null, 2)}\n`);
|
||
|
||
await updateConversationAction("mac-studio:/users/kris/code/yuandi", "toggle_pin");
|
||
let nextState = await readState();
|
||
assert.deepEqual(
|
||
nextState.projects
|
||
.filter((project) => project.id.startsWith("yuandi-thread-"))
|
||
.map((project) => project.pinned),
|
||
[true, true],
|
||
);
|
||
|
||
await updateConversationAction("mac-studio:/users/kris/code/yuandi", "toggle_pin");
|
||
nextState = await readState();
|
||
assert.deepEqual(
|
||
nextState.projects
|
||
.filter((project) => project.id.startsWith("yuandi-thread-"))
|
||
.map((project) => project.pinned),
|
||
[false, false],
|
||
);
|
||
} finally {
|
||
await resetSeedState();
|
||
}
|
||
});
|
||
|
||
test("conversation home groups multiple imported threads by folder while keeping single-thread projects direct", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-1",
|
||
"Boss",
|
||
"boss",
|
||
"归档确认",
|
||
"thread-1",
|
||
"2026-03-30T11:00:00+08:00",
|
||
),
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-2",
|
||
"Boss",
|
||
"boss",
|
||
"发布回滚",
|
||
"thread-2",
|
||
"2026-03-30T12:00:00+08:00",
|
||
),
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"yuandi-thread-1",
|
||
"源地",
|
||
"yuandi",
|
||
"首页回归",
|
||
"thread-3",
|
||
"2026-03-30T10:00:00+08:00",
|
||
),
|
||
);
|
||
|
||
const homeItems = getConversationHomeItems(state);
|
||
const bossFolder = homeItems.find((item) => item.conversationType === "folder_archive");
|
||
const directThread = homeItems.find((item) => item.projectId === "yuandi-thread-1");
|
||
|
||
assert.ok(bossFolder, "expected grouped folder item for multi-thread project");
|
||
assert.equal(bossFolder?.threadTitle, "Boss");
|
||
assert.equal(bossFolder?.threadCount, 2);
|
||
assert.equal(bossFolder?.folderKey, "mac-studio:boss");
|
||
|
||
assert.ok(directThread, "expected single-thread project to stay direct");
|
||
assert.equal(directThread?.conversationType, "single_device");
|
||
assert.equal(directThread?.threadTitle, "首页回归");
|
||
|
||
const folderView = getConversationFolderView(state, "mac-studio:boss");
|
||
assert.ok(folderView, "expected folder detail view");
|
||
assert.equal(folderView?.threadCount, 2);
|
||
assert.deepEqual(
|
||
folderView?.threads.map((item) => item.threadTitle),
|
||
["发布回滚", "归档确认"],
|
||
);
|
||
});
|
||
|
||
test("folder archive homepage rows keep the project title, compact subtitle, and folder route", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-1",
|
||
"Boss",
|
||
"boss",
|
||
"归档确认",
|
||
"thread-1",
|
||
"2026-03-30T11:00:00+08:00",
|
||
),
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-2",
|
||
"Boss",
|
||
"boss",
|
||
"发布回滚",
|
||
"thread-2",
|
||
"2026-03-30T12:00:00+08:00",
|
||
),
|
||
);
|
||
|
||
const folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive");
|
||
|
||
assert.ok(folder, "expected grouped folder archive item");
|
||
assert.deepEqual(folder?.searchAliases, ["发布回滚", "归档确认"]);
|
||
const presentation = getConversationListItemPresentation({
|
||
...folder!,
|
||
projectTitle: "项目标题",
|
||
threadTitle: "线程标题",
|
||
});
|
||
|
||
assert.equal(presentation.title, "项目标题");
|
||
assert.equal(presentation.subtitle, "2 个线程 · 最近:发布回滚");
|
||
assert.equal(presentation.href, "/conversations/folders/mac-studio%3Aboss");
|
||
});
|
||
|
||
test("conversation home compacts imported previews and trims local workspace prefixes from folder subtitles", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
{
|
||
...buildImportedThreadProject(
|
||
"mac-studio",
|
||
"wenshen-thread-1",
|
||
"wenshenapp",
|
||
"/Users/kris/code/wenshenapp",
|
||
"/Users/kris/code/wenshenapp/docs/superpowers/specs/2026-04-06-context-cleanup-design.md",
|
||
"thread-1",
|
||
"2026-04-06T15:00:00+08:00",
|
||
),
|
||
preview: "已从设备 Mac Studio 导入线程《/Users/kris/code/wenshenapp/docs/superpowers/specs/2026-04-06-context-cleanup-design.md》。",
|
||
lastMessageAt: "2026-04-06T15:00:00+08:00",
|
||
},
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"wenshen-thread-2",
|
||
"wenshenapp",
|
||
"/Users/kris/code/wenshenapp",
|
||
"补齐 Android 会话可读性",
|
||
"thread-2",
|
||
"2026-04-06T14:00:00+08:00",
|
||
),
|
||
);
|
||
|
||
const folder = getConversationHomeItems(state).find((item) => item.projectId === "mac-studio:/users/kris/code/wenshenapp");
|
||
|
||
assert.ok(folder, "expected grouped folder archive item");
|
||
assert.equal(folder?.conversationType, "folder_archive");
|
||
assert.equal(folder?.folderLabel, "2 个线程 · 最近:wenshenapp/docs/superpowers/specs/2026-04-06-context-cleanup-design.md");
|
||
assert.equal(folder?.preview, "已导入线程");
|
||
assert.equal(folder?.lastMessagePreview, "已导入线程");
|
||
});
|
||
|
||
test("conversation home compacts imported previews and trims local workspace prefixes for single-thread items", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push({
|
||
...buildImportedThreadProject(
|
||
"mac-studio",
|
||
"zhanglaoshi-thread-1",
|
||
"zhanglaoshi",
|
||
"/Users/kris/code/zhanglaoshi",
|
||
"/Users/kris/code/zhanglaoshi/docs/superpowers/specs/2026-04-06-single-thread-cleanup-design.md",
|
||
"thread-1",
|
||
"2026-04-06T15:00:00+08:00",
|
||
),
|
||
preview: "已从设备 Mac Studio 导入线程《zhanglaoshi · 019d61》。",
|
||
lastMessageAt: "2026-04-06T15:00:00+08:00",
|
||
});
|
||
|
||
const item = getConversationHomeItems(state).find((entry) => entry.projectId === "zhanglaoshi-thread-1");
|
||
|
||
assert.ok(item, "expected single imported thread item");
|
||
assert.equal(item?.conversationType, "single_device");
|
||
assert.equal(item?.threadTitle, "zhanglaoshi/docs/superpowers/specs/2026-04-06-single-thread-cleanup-design.md");
|
||
assert.equal(item?.preview, "已导入线程");
|
||
assert.equal(item?.lastMessagePreview, "已导入线程");
|
||
});
|
||
|
||
test("conversation home sanitizes leaked prompt titles to folder fallbacks", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-prompt",
|
||
"boss",
|
||
"/Users/kris/code/boss",
|
||
"你当前接手的项目根目录是:",
|
||
"thread-prompt",
|
||
"2026-04-24T10:00:00+08:00",
|
||
),
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"yuandi-thread-prompt",
|
||
"yuandi",
|
||
"/Users/kris/code/yuandi",
|
||
"你现在接手的项目根目录是 /Users/kris/code/yuandi。",
|
||
"thread-prompt-2",
|
||
"2026-04-24T10:05:00+08:00",
|
||
),
|
||
);
|
||
|
||
const bossItem = getConversationHomeItems(state).find((item) => item.projectId === "boss-thread-prompt");
|
||
const yuandiItem = getConversationHomeItems(state).find((item) => item.projectId === "yuandi-thread-prompt");
|
||
|
||
assert.ok(bossItem, "expected prompt-leak boss item");
|
||
assert.equal(bossItem?.conversationType, "single_device");
|
||
assert.equal(bossItem?.projectTitle, "boss");
|
||
assert.equal(bossItem?.threadTitle, "boss");
|
||
|
||
assert.ok(yuandiItem, "expected prompt-leak yuandi item");
|
||
assert.equal(yuandiItem?.conversationType, "single_device");
|
||
assert.equal(yuandiItem?.projectTitle, "yuandi");
|
||
assert.equal(yuandiItem?.threadTitle, "yuandi");
|
||
});
|
||
|
||
test("project detail view sanitizes leaked prompt title for single-thread projects", async () => {
|
||
await setup();
|
||
await resetSeedState();
|
||
const state = await readState();
|
||
const project = buildImportedThreadProject(
|
||
"mac-studio",
|
||
"detail-sanitize-thread",
|
||
"boss",
|
||
"boss",
|
||
"你当前接手的项目根目录是:",
|
||
"detail-sanitize-thread-id",
|
||
"2026-04-24T12:00:00+08:00",
|
||
);
|
||
state.projects.push(project);
|
||
|
||
project.name = "你当前接手的项目根目录是:";
|
||
project.threadMeta.threadDisplayName = "你当前接手的项目根目录是:";
|
||
project.threadMeta.folderName = "boss";
|
||
project.threadMeta.codexFolderRef = "boss";
|
||
|
||
const detail = getProjectDetailView(state, project.id);
|
||
|
||
assert.ok(detail, "expected project detail");
|
||
assert.equal(detail?.project.name, "boss");
|
||
assert.equal(detail?.project.threadMeta.threadDisplayName, "boss");
|
||
});
|
||
|
||
test("project messages realtime payload sanitizes leaked prompt title for thread chat header", async () => {
|
||
await setup();
|
||
await resetSeedState();
|
||
const state = await readState();
|
||
const project = buildImportedThreadProject(
|
||
"mac-studio",
|
||
"messages-sanitize-thread",
|
||
"yuandi",
|
||
"yuandi",
|
||
"你现在接手的项目根目录是 /Users/kris/code/yuandi。",
|
||
"messages-sanitize-thread-id",
|
||
"2026-04-24T12:01:00+08:00",
|
||
);
|
||
state.projects.push(project);
|
||
|
||
project.name = "你现在接手的项目根目录是 /Users/kris/code/yuandi。";
|
||
project.threadMeta.threadDisplayName = "你现在接手的项目根目录是 /Users/kris/code/yuandi。";
|
||
project.threadMeta.folderName = "";
|
||
project.threadMeta.codexFolderRef = "yuandi";
|
||
|
||
const payload = buildProjectMessagesRealtimePayload(state, project.id);
|
||
|
||
assert.ok(payload, "expected realtime payload");
|
||
assert.equal(payload?.project.name, "yuandi");
|
||
assert.equal(payload?.project.threadMeta.threadDisplayName, "yuandi");
|
||
});
|
||
|
||
test("folder archive homepage rows do not expose pin toggles in the Web surface", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
{
|
||
...buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-1",
|
||
"Boss",
|
||
"boss",
|
||
"归档确认",
|
||
"thread-1",
|
||
"2026-03-30T11:00:00+08:00",
|
||
),
|
||
pinned: true,
|
||
},
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-2",
|
||
"Boss",
|
||
"boss",
|
||
"发布回滚",
|
||
"thread-2",
|
||
"2026-03-30T12:00:00+08:00",
|
||
),
|
||
);
|
||
|
||
const folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive");
|
||
assert.ok(folder, "expected grouped folder archive item");
|
||
|
||
const actions = getConversationActionAvailability(folder!);
|
||
assert.equal(actions.canTogglePin, false);
|
||
});
|
||
|
||
test("homepage rows do not expose pinned labels in the Web surface", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push({
|
||
...buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-1",
|
||
"Boss",
|
||
"boss",
|
||
"归档确认",
|
||
"thread-1",
|
||
"2026-03-30T11:00:00+08:00",
|
||
),
|
||
pinned: true,
|
||
});
|
||
|
||
const pinnedThread = getConversationHomeItems(state).find((item) => item.projectId === "boss-thread-1");
|
||
assert.ok(pinnedThread, "expected pinned thread item");
|
||
assert.equal(getConversationPinnedBadgeLabel(pinnedThread!), "");
|
||
|
||
const masterAgent = getConversationHomeItems(state).find((item) => item.projectId === "master-agent");
|
||
assert.ok(masterAgent, "expected master agent item");
|
||
assert.equal(getConversationPinnedBadgeLabel(masterAgent!), "");
|
||
});
|
||
|
||
test("web conversation projection strips pin metadata before rendering the Mac/Web surface", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
{
|
||
...buildImportedThreadProject(
|
||
"mac-studio",
|
||
"solo-pinned-thread",
|
||
"Solo",
|
||
"solo",
|
||
"单线程置顶验证",
|
||
"solo-thread",
|
||
"2026-03-30T13:00:00+08:00",
|
||
),
|
||
pinned: true,
|
||
},
|
||
{
|
||
...buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-1",
|
||
"Boss",
|
||
"boss",
|
||
"归档确认",
|
||
"thread-1",
|
||
"2026-03-30T11:00:00+08:00",
|
||
),
|
||
pinned: true,
|
||
},
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-2",
|
||
"Boss",
|
||
"boss",
|
||
"发布回滚",
|
||
"thread-2",
|
||
"2026-03-30T12:00:00+08:00",
|
||
),
|
||
);
|
||
|
||
const webItems = getConversationWebItems(state);
|
||
const pinnedThread = webItems.find((item) => item.projectId === "solo-pinned-thread");
|
||
const folder = webItems.find((item) => item.conversationType === "folder_archive");
|
||
const masterAgent = webItems.find((item) => item.projectId === "master-agent");
|
||
|
||
assert.ok(pinnedThread, "expected pinned thread in web projection");
|
||
assert.equal(pinnedThread?.topPinnedLabel, undefined);
|
||
assert.equal(pinnedThread?.manualPinned, false);
|
||
|
||
assert.ok(folder, "expected folder archive in web projection");
|
||
assert.equal(folder?.topPinnedLabel, undefined);
|
||
assert.equal(folder?.manualPinned, false);
|
||
|
||
assert.ok(masterAgent, "expected master agent in web projection");
|
||
assert.equal(masterAgent?.topPinnedLabel, undefined);
|
||
assert.equal(masterAgent?.manualPinned, false);
|
||
});
|
||
|
||
test("folder archive action path encodes folder keys with nested path segments", async () => {
|
||
await setup();
|
||
|
||
assert.equal(
|
||
getConversationActionsPath("mac-studio:/Users/kris/code/yuandi"),
|
||
"/api/v1/conversations/mac-studio%3A%2FUsers%2Fkris%2Fcode%2Fyuandi/actions",
|
||
);
|
||
});
|
||
|
||
test("folder archive search aliases keep full reachability across five threads", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject("mac-studio", "boss-thread-1", "Boss", "boss", "发布回滚", "thread-1", "2026-03-30T14:00:00+08:00"),
|
||
buildImportedThreadProject("mac-studio", "boss-thread-2", "Boss", "boss", "Android UI 收尾", "thread-2", "2026-03-30T13:00:00+08:00"),
|
||
buildImportedThreadProject("mac-studio", "boss-thread-3", "Boss", "boss", "日志收口", "thread-3", "2026-03-30T12:00:00+08:00"),
|
||
buildImportedThreadProject("mac-studio", "boss-thread-4", "Boss", "boss", "网络修复", "thread-4", "2026-03-30T11:00:00+08:00"),
|
||
buildImportedThreadProject("mac-studio", "boss-thread-5", "Boss", "boss", "审阅确认", "thread-5", "2026-03-30T10:00:00+08:00"),
|
||
);
|
||
|
||
const folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive");
|
||
|
||
assert.ok(folder, "expected grouped folder archive item");
|
||
assert.deepEqual(folder?.searchAliases, [
|
||
"发布回滚",
|
||
"Android UI 收尾",
|
||
"日志收口",
|
||
"网络修复",
|
||
"审阅确认",
|
||
]);
|
||
assert.deepEqual(folder?.searchTargetProjectIds, [
|
||
"boss-thread-1",
|
||
"boss-thread-2",
|
||
"boss-thread-3",
|
||
"boss-thread-4",
|
||
"boss-thread-5",
|
||
]);
|
||
});
|
||
|
||
test("folder archive search aliases keep same-label threads reachable through every project id", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject("mac-studio", "boss-thread-1", "Boss", "boss", "发布回滚", "thread-1", "2026-03-30T14:00:00+08:00"),
|
||
buildImportedThreadProject("mac-studio", "boss-thread-2", "Boss", "boss", "发布回滚", "thread-2", "2026-03-30T13:00:00+08:00"),
|
||
buildImportedThreadProject("mac-studio", "boss-thread-3", "Boss", "boss", "日志收口", "thread-3", "2026-03-30T12:00:00+08:00"),
|
||
buildImportedThreadProject("mac-studio", "boss-thread-4", "Boss", "boss", "网络修复", "thread-4", "2026-03-30T11:00:00+08:00"),
|
||
);
|
||
|
||
const folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive");
|
||
|
||
assert.ok(folder, "expected grouped folder archive item");
|
||
assert.deepEqual(folder?.searchAliases, ["发布回滚", "发布回滚", "日志收口", "网络修复"]);
|
||
assert.deepEqual(folder?.searchTargetProjectIds, ["boss-thread-1", "boss-thread-2", "boss-thread-3", "boss-thread-4"]);
|
||
});
|
||
|
||
test("conversation items expose context status while keeping idle activity silent", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.masterAgentTasks = [];
|
||
state.dispatchExecutions = [];
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"boss-thread-1",
|
||
"Boss",
|
||
"boss",
|
||
"归档确认",
|
||
"thread-1",
|
||
"2026-03-30T11:00:00+08:00",
|
||
),
|
||
);
|
||
state.threadContextSnapshots = [
|
||
{
|
||
snapshotId: "snapshot-1",
|
||
workerId: "mac-studio",
|
||
projectId: "boss-thread-1",
|
||
threadId: "thread-1",
|
||
title: "归档确认",
|
||
summary: "上下文预算进入紧张区,需要尽快收尾。",
|
||
contextBudgetRemainingPct: 34,
|
||
contextBudgetLevel: "urgent",
|
||
mustFinishBeforeCompaction: false,
|
||
estimatedRemainingTurns: 8,
|
||
estimatedRemainingLargeMessages: 3,
|
||
compactionCount: 0,
|
||
patchPending: false,
|
||
testsPending: false,
|
||
evidencePending: false,
|
||
checklist: [],
|
||
capturedAt: "2026-03-30T11:00:00+08:00",
|
||
},
|
||
];
|
||
|
||
const [masterAgent, threadConversation] = getConversationHomeItems(state);
|
||
|
||
assert.equal(threadConversation.projectId, "boss-thread-1");
|
||
assert.equal(threadConversation.contextBudgetIndicator.visible, true);
|
||
assert.equal(threadConversation.contextBudgetIndicator.percent, 34);
|
||
assert.equal(threadConversation.contextBudgetIndicator.level, "urgent");
|
||
assert.equal(threadConversation.activityIconCount, 0);
|
||
assert.equal(masterAgent.activityIconCount, 0);
|
||
assert.equal(masterAgent.contextBudgetIndicator.visible, false);
|
||
assert.equal(masterAgent.contextBudgetIndicator.percent, undefined);
|
||
assert.equal(masterAgent.contextBudgetIndicator.level, undefined);
|
||
});
|
||
|
||
test("conversation items hide context ring when no thread snapshot exists", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
buildImportedThreadProject(
|
||
"mac-studio",
|
||
"single-thread-no-context",
|
||
"Talking",
|
||
"talking",
|
||
"调试回归",
|
||
"thread-no-context",
|
||
"2026-03-30T11:20:00+08:00",
|
||
),
|
||
);
|
||
state.threadContextSnapshots = [];
|
||
|
||
const items = getConversationHomeItems(state);
|
||
const directThread = items.find((item) => item.projectId === "single-thread-no-context");
|
||
|
||
assert.ok(directThread);
|
||
assert.equal(directThread?.contextBudgetIndicator.visible, false);
|
||
assert.equal(directThread?.contextBudgetIndicator.percent, undefined);
|
||
assert.equal(directThread?.contextBudgetIndicator.level, undefined);
|
||
});
|
||
|
||
test("conversation items keep latest reply ordering anchored to actual message time", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
const staleProject = buildImportedThreadProject(
|
||
"mac-studio",
|
||
"stale-thread",
|
||
"Talking",
|
||
"talking",
|
||
"树莓派二代查询",
|
||
"thread-stale",
|
||
"2026-04-04T06:12:00+08:00",
|
||
);
|
||
const freshProject = buildImportedThreadProject(
|
||
"mac-studio",
|
||
"fresh-thread",
|
||
"Fresh",
|
||
"fresh",
|
||
"最近真回复",
|
||
"thread-fresh",
|
||
"2026-04-04T12:10:00+08:00",
|
||
);
|
||
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push(
|
||
freshProject,
|
||
{
|
||
...staleProject,
|
||
threadMeta: {
|
||
...staleProject.threadMeta,
|
||
lastObservedCodexActivityAt: "2026-04-04T11:48:00+08:00",
|
||
},
|
||
projectUnderstanding: {
|
||
projectGoal: "保持会话列表稳定",
|
||
currentProgress: "后台只更新状态文档",
|
||
technicalArchitecture: "Boss 会话页",
|
||
currentBlockers: "",
|
||
recommendedNextStep: "不要让非消息事件抬升排序",
|
||
updatedAt: "2026-04-04T12:08:00+08:00",
|
||
},
|
||
},
|
||
);
|
||
|
||
const items = getConversationHomeItems(state);
|
||
const thread = items.find((item) => item.projectId === "stale-thread");
|
||
const freshThreadIndex = items.findIndex((item) => item.projectId === "fresh-thread");
|
||
const staleThreadIndex = items.findIndex((item) => item.projectId === "stale-thread");
|
||
|
||
assert.ok(thread);
|
||
assert.ok(freshThreadIndex >= 0);
|
||
assert.ok(staleThreadIndex >= 0);
|
||
assert.ok(freshThreadIndex < staleThreadIndex, "后台活动不应把旧会话抬到真实新消息前面");
|
||
assert.equal(thread?.latestReplyAt, "2026-04-04T06:12:00+08:00");
|
||
assert.equal(thread?.latestReplyLabel, formatTimestampLabel("2026-04-04T06:12:00+08:00"));
|
||
});
|
||
|
||
test("conversation items mark stale context-backed timestamps as waiting for sync", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||
state.projects.push({
|
||
...buildImportedThreadProject(
|
||
"mac-studio",
|
||
"stale-context-project",
|
||
"Boss",
|
||
"boss",
|
||
"上下文老化线程",
|
||
"thread-stale",
|
||
"2026-03-25T10:58:00+08:00",
|
||
),
|
||
preview: "老上下文还在挂着",
|
||
unreadCount: 1,
|
||
});
|
||
state.threadContextSnapshots.push(
|
||
buildThreadContextSnapshot(
|
||
"stale-context-project",
|
||
"thread-stale",
|
||
"上下文老化线程",
|
||
"watch",
|
||
false,
|
||
58,
|
||
"2026-03-25T10:58:00+08:00",
|
||
"mac-studio",
|
||
),
|
||
);
|
||
|
||
const item = getConversationHomeItems(state).find((entry) => entry.projectId === "stale-context-project");
|
||
|
||
assert.ok(item, "expected stale context conversation");
|
||
assert.equal(item?.contextBudgetIndicator.visible, true);
|
||
assert.equal(item?.latestReplyLabel, "待同步");
|
||
});
|
||
|
||
test("default seeded conversations no longer expose Boss 移动控制台", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
const items = getConversationHomeItems(state);
|
||
|
||
assert.ok(items.some((item) => item.projectId === "master-agent"), "expected master-agent to remain available");
|
||
assert.ok(items.some((item) => item.projectId === "audit-collab"), "expected audit collaboration to remain available");
|
||
assert.equal(
|
||
items.some((item) => item.projectId === "boss-console" || item.threadTitle === "Boss 移动控制台"),
|
||
false,
|
||
);
|
||
});
|
||
|
||
test("readState migrates away persisted legacy boss-console conversations", async () => {
|
||
await setup();
|
||
const state = await readState();
|
||
|
||
const legacyState = structuredClone(state) as typeof state;
|
||
legacyState.projects.push({
|
||
id: "boss-console",
|
||
name: "Boss 移动控制台",
|
||
pinned: false,
|
||
systemPinned: false,
|
||
deviceIds: ["mac-studio"],
|
||
preview: "历史遗留的 boss-console 会话。",
|
||
updatedAt: "2026-04-04T12:20:00+08:00",
|
||
lastMessageAt: "2026-04-04T12:20:00+08:00",
|
||
isGroup: false,
|
||
threadMeta: {
|
||
projectId: "boss-console",
|
||
threadId: "thread-boss-console",
|
||
threadDisplayName: "Boss 移动控制台",
|
||
folderName: "Boss",
|
||
activityIconCount: 1,
|
||
updatedAt: "2026-04-04T12:20:00+08:00",
|
||
codexThreadRef: "thread-boss-console",
|
||
codexFolderRef: "boss-console",
|
||
},
|
||
groupMembers: [],
|
||
createdByAgent: true,
|
||
collaborationMode: "development",
|
||
approvalState: "not_required",
|
||
unreadCount: 0,
|
||
riskLevel: "low",
|
||
messages: [],
|
||
goals: [],
|
||
versions: [],
|
||
});
|
||
legacyState.threadContextSnapshots.push({
|
||
snapshotId: "legacy-boss-console-snapshot",
|
||
projectId: "boss-console",
|
||
taskId: "legacy-task",
|
||
threadId: "thread-boss-console",
|
||
title: "Boss 移动控制台线程",
|
||
summary: "遗留摘要",
|
||
nodeId: "mac-studio",
|
||
workerId: "worker-legacy",
|
||
sourceKind: "worker_estimator",
|
||
status: "running",
|
||
contextBudgetRemainingPct: 48,
|
||
contextBudgetLevel: "watch",
|
||
mustFinishBeforeCompaction: false,
|
||
estimatedRemainingTurns: 5,
|
||
estimatedRemainingLargeMessages: 2,
|
||
compactionCount: 0,
|
||
patchPending: false,
|
||
testsPending: false,
|
||
evidencePending: false,
|
||
checklist: [],
|
||
capturedAt: "2026-04-04T12:20:00+08:00",
|
||
});
|
||
|
||
const stateFile = process.env.BOSS_STATE_FILE;
|
||
assert.ok(stateFile, "expected test state file path");
|
||
await writeFile(stateFile, JSON.stringify(legacyState, null, 2), "utf8");
|
||
|
||
const migratedState = await readState();
|
||
assert.equal(migratedState.projects.some((item) => item.id === "boss-console"), false);
|
||
assert.equal(migratedState.threadContextSnapshots.some((item) => item.projectId === "boss-console"), false);
|
||
});
|