Files
boss/tests/conversation-home-items.test.ts

938 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 updateConversationAction: (typeof import("../src/lib/boss-data"))["updateConversationAction"];
let getConversationHomeItems: (typeof import("../src/lib/boss-projections"))["getConversationHomeItems"];
let getConversationFolderView: (typeof import("../src/lib/boss-projections"))["getConversationFolderView"];
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"];
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;
updateConversationAction = data.updateConversationAction;
getConversationHomeItems = projections.getConversationHomeItems;
getConversationFolderView = projections.getConversationFolderView;
formatTimestampLabel = projections.formatTimestampLabel;
getConversationListItemPresentation = ui.getConversationListItemPresentation;
getConversationActionAvailability = ui.getConversationActionAvailability;
getConversationActionsPath = ui.getConversationActionsPath;
}
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 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");
});
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 upgrades and downgrades a folder archive as thread count crosses 1 and 2", 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",
),
);
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,
);
});
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();
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],
);
});
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("folder archive homepage rows expose pin toggles when the folder is pinned", 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, true);
assert.equal(actions.togglePinLabel, "取消置顶");
});
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 prefer latest observed codex activity over stale last message time", async () => {
await setup();
const state = await readState();
const baseProject = buildImportedThreadProject(
"mac-studio",
"stale-thread",
"Talking",
"talking",
"树莓派二代查询",
"thread-stale",
"2026-04-04T06:12:00+08:00",
);
state.projects = state.projects.filter((project) => project.id === "master-agent");
state.projects.push(
{
...baseProject,
threadMeta: {
...baseProject.threadMeta,
lastObservedCodexActivityAt: "2026-04-04T11:48:00+08:00",
},
},
);
const items = getConversationHomeItems(state);
const thread = items.find((item) => item.projectId === "stale-thread");
assert.ok(thread);
assert.equal(thread?.latestReplyAt, "2026-04-04T11:48:00+08:00");
assert.equal(thread?.latestReplyLabel, formatTimestampLabel("2026-04-04T11:48: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.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);
});