Integrate master agent runtime orchestration updates

This commit is contained in:
kris
2026-04-16 04:41:46 +08:00
parent e0c0ea1814
commit 39be49630f
81 changed files with 9283 additions and 448 deletions

View File

@@ -39,6 +39,81 @@ test("ProjectDetailActivity keeps a rendered project snapshot for append-only re
/private boolean trySkipUnchangedRealtimeMessagesPatch\(JSONObject projectMessagesPayload\)/,
"expected chat page to expose a duplicate-payload fast path",
);
assert.match(
source,
/private boolean hasMatchingExecutionWarnings\(JSONObject currentPayload,\s*JSONObject nextPayload\)/,
"expected chat page to compare executionWarnings separately from the message list",
);
assert.match(
source,
/private boolean hasMatchingConversationTasks\(JSONObject currentPayload,\s*JSONObject nextPayload\)/,
"expected chat page to compare conversationTasks separately from the message list",
);
assert.match(
source,
/if \(!hasMatchingExecutionWarnings\(currentRenderedProjectPayload,\s*projectMessagesPayload\)\) \{\s*return false;\s*\}/,
"expected append-only realtime patches to fall back when warning payloads changed",
);
assert.match(
source,
/if \(!hasMatchingConversationTasks\(currentRenderedProjectPayload,\s*projectMessagesPayload\)\) \{\s*return false;\s*\}/,
"expected append-only realtime patches to fall back when task payloads changed",
);
assert.match(
source,
/JSONObject conversationTask = findConversationTask\(currentRenderedProjectPayload,\s*messageId\);/,
"expected each message view to look up a task summary by request message id",
);
assert.match(
source,
/if \(tryPatchRealtimeExecutionWarnings\(projectMessagesPayload\)\) \{\s*return true;\s*\}/,
"expected chat page to patch warning-only realtime changes before falling back to full rerender",
);
assert.match(
source,
/private boolean tryPatchRealtimeExecutionWarnings\(JSONObject projectMessagesPayload\)/,
"expected chat page to expose a focused warning patch helper",
);
assert.match(
source,
/replaceMessageViewById\(messageId,\s*buildMessageView\(message\)\);/,
"expected warning-only patches to rerender only the affected message view",
);
assert.match(
source,
/private void replaceMessageViewById\(String messageId,\s*View nextMessageView\)/,
"expected chat page to expose a helper for targeted message view replacement",
);
assert.match(
source,
/wrapper\.addView\(statusRow\);/,
"expected each message bubble to append a compact status row",
);
assert.match(
source,
/List<JSONObject> messageWarnings = buildMessageWarnings\(currentRenderedProjectPayload,\s*messageId\);/,
"expected message views to gather grouped warnings for the status row",
);
assert.match(
source,
/String currentFingerprint = buildStatusFingerprint\(messageId, currentRenderedProjectPayload\);/,
"expected realtime patches to compute the current status fingerprint before replacing a message view",
);
assert.match(
source,
/String nextFingerprint = buildStatusFingerprint\(messageId, projectMessagesPayload\);/,
"expected realtime patches to compute a fingerprint before replacing a message view",
);
assert.match(
source,
/if \(!TextUtils\.equals\(currentFingerprint,\s*nextFingerprint\)\) \{/,
"expected realtime warning patches to branch on status fingerprint changes before replacing views",
);
assert.match(
source,
/if \(hasMatchingExecutionWarnings\(currentRenderedProjectPayload,\s*projectMessagesPayload\)\s*&&\s*hasMatchingConversationTasks\(currentRenderedProjectPayload,\s*projectMessagesPayload\)\) \{\s*return false;\s*\}/,
"expected status-only patch path to stay idle only when both warnings and task payloads are unchanged",
);
});
test("ProjectDetailActivity suppresses intermediate layouts while rebuilding or appending chat content", async () => {

View File

@@ -34,4 +34,75 @@ test("ProjectDetailActivity applies lightweight realtime chat payloads before sc
/renderLoadedProjectSnapshot\(new ProjectSnapshot\(projectMessagesPayload,\s*null,\s*null\)\);/,
"expected chat page to render the local realtime payload without forcing a network request",
);
assert.match(
source,
/JSONArray executionWarnings = projectMessagesPayload\.optJSONArray\("executionWarnings"\);/,
"expected chat page to read executionWarnings from the lightweight realtime payload",
);
assert.match(
source,
/LinearLayout statusRow = BossUi\.buildMessageStatusRow\(this, message, conversationTask, messageWarnings, outgoing\);/,
"expected each rendered message to create a compact status row",
);
assert.match(
source,
/private List<JSONObject> buildMessageWarnings\(JSONObject payload, String messageId\)/,
"expected a helper returning grouped warnings per message",
);
assert.match(
source,
/if \(!TextUtils\.equals\(currentFingerprint,\s*nextFingerprint\)\) \{/,
"expected realtime warning patches to branch on fingerprint differences before replacing views",
);
assert.match(
source,
/replaceMessageViewById\(messageId,\s*buildMessageView\(message\)\);/,
"expected realtime warning patches to replace only the affected message after fingerprint differences",
);
const warningPatchMethod = source.match(
/private boolean tryPatchRealtimeExecutionWarnings\(JSONObject projectMessagesPayload\) \{[\s\S]*?\n \}/,
);
assert.ok(warningPatchMethod, "expected to locate the warning patch helper body");
const snapshotSwapCount =
warningPatchMethod[0].match(/currentRenderedProjectPayload = nextPayloadCopy;/g)?.length ?? 0;
assert.equal(
snapshotSwapCount,
1,
"expected warning patch helper to swap the rendered payload only once after all message diffs are processed",
);
});
test("BossUi keeps a detail-only message status row visible", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/BossUi.java");
assert.match(
source,
/boolean hasDetail = !TextUtils\.isEmpty\(detailText\);/,
"expected message status rows to detect detail-only status text",
);
assert.match(
source,
/if \(!hasTask && !hasWarnings && !hasDetail\) \{\s*row\.setVisibility\(View\.GONE\);\s*return row;\s*\}/,
"expected message status rows to stay visible whenever detail text exists",
);
assert.match(
source,
/if \(hasDetail\) \{\s*TextView detailView = new TextView\(context\);/,
"expected detail-only rows to still render their muted status text",
);
});
test("ProjectDetailActivity bypasses realtime message-only patching when group dispatch or repair state is active", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java");
assert.match(
source,
/if \(shouldBypassRealtimeMessagesPatchForGroupState\(\)\) \{\s*return false;\s*\}/,
"expected realtime message patching to fall back to a full reload when group dispatch or repair state could be stale",
);
assert.match(
source,
/private boolean shouldBypassRealtimeMessagesPatchForGroupState\(\) \{/,
"expected a dedicated helper guarding the fast patch path for group-only state",
);
});

View File

@@ -0,0 +1,122 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
async function readSource(path: string) {
return readFile(new URL(path, import.meta.url), "utf8");
}
test("ProjectDetailActivity reads group dispatch and participant state from project detail payload", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java");
assert.match(
source,
/detailResponse\.json\.optJSONArray\("dispatchPlans"\)/,
"expected project chat detail refreshes to read dispatchPlans directly from project detail payload",
);
assert.match(
source,
/detailResponse\.json\.optJSONObject\("participantsPayload"\)/,
"expected project chat detail refreshes to read participantsPayload directly from project detail payload",
);
assert.doesNotMatch(
source,
/apiClient\.getDispatchPlans\(projectId\)/,
"expected project chat detail refreshes to stop issuing a separate dispatch plans request",
);
assert.doesNotMatch(
source,
/apiClient\.getConversationParticipants\(projectId\)/,
"expected project chat detail refreshes to stop issuing a separate participants request",
);
});
test("GroupInfoActivity derives participants state from project detail payload", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java");
assert.match(
source,
/JSONObject participantsPayload = extractParticipantsPayload\(detailResponse\.json\);/,
"expected group info page to derive participants from the detail payload before rendering",
);
assert.doesNotMatch(
source,
/apiClient\.getConversationParticipants\(projectId\)/,
"expected group info page to stop issuing a separate participants request",
);
assert.match(
source,
/groupRepairJustApplied = true;/,
"expected group info page to persist a successful repair flag across the immediate reload",
);
assert.match(
source,
/if \(groupRepairJustApplied\) \{/,
"expected group info page to render a durable success acknowledgement after repair reload",
);
assert.match(
source,
/群成员已更新/,
"expected group info page to show explicit success copy after a repair completes",
);
});
test("ConversationInfoActivity derives participants state from project detail payload", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java");
assert.match(
source,
/JSONObject participantsPayload = extractParticipantsPayload\(detailResponse\.json\);/,
"expected conversation info page to derive participants from the detail payload before rendering",
);
assert.doesNotMatch(
source,
/apiClient\.getConversationParticipants\(projectId\)/,
"expected conversation info page to stop issuing a separate participants request",
);
});
test("GroupCreateActivity reuses project detail payload when launched from a source conversation", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java");
assert.match(
source,
/BossApiClient\.ApiResponse detailResponse = apiClient\.getProjectDetail\(sourceProjectId\);/,
"expected group creation from a source conversation to load source detail once",
);
assert.match(
source,
/JSONObject participantsPayload = extractParticipantsPayload\(detailResponse\.json,\s*sourceProjectId\);/,
"expected group creation page to derive source participants from project detail payload",
);
assert.doesNotMatch(
source,
/apiClient\.getConversationParticipants\(sourceProjectId\)/,
"expected group creation page to stop issuing a separate participants request for the source conversation",
);
});
test("BossApiClient no longer keeps unused group detail compatibility wrappers", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/BossApiClient.java");
assert.doesNotMatch(
source,
/public ApiResponse getDispatchPlans\(String projectId\)/,
"expected BossApiClient to drop the unused dispatch plans wrapper once all screens read from project detail",
);
assert.doesNotMatch(
source,
/public ApiResponse getConversationParticipants\(String projectId\)/,
"expected BossApiClient to drop the unused participants wrapper once all screens read from project detail",
);
});
test("ProjectDetailActivity uses a distinct repair meta copy when only one valid thread remains", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java");
assert.match(
source,
/validParticipantCount > 0[\s\S]*当前仅有 "\s*\+\s*validParticipantCount\s*\+\s*" 个真实线程成员/,
"expected group repair card copy to distinguish one-valid-thread state from zero-valid-thread state",
);
});

View File

@@ -0,0 +1,52 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
async function readSource(path: string) {
return readFile(new URL(path, import.meta.url), "utf8");
}
test("ProjectChatUiState tracks dispatch execution ids for reply wait after confirming a group dispatch", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java");
assert.match(
source,
/public final List<String> executionIds;/,
"expected ReplyWaitSpec to retain the dispatch execution ids it is waiting on",
);
assert.match(
source,
/JSONArray executions = response\.optJSONArray\("executions"\);/,
"expected dispatch confirm wait resolution to inspect the executions returned by the server",
);
assert.match(
source,
/collectExecutionIds\(executions\)/,
"expected dispatch confirm wait resolution to normalize execution ids into the wait spec",
);
assert.match(
source,
/hasTrackedDispatchExecutionReply\(\s*@Nullable JSONArray dispatchPlans,\s*@Nullable List<String> executionIds\s*\)/,
"expected a helper that checks reply progress against tracked dispatch executions",
);
});
test("ProjectDetailActivity polls dispatch reply waits against tracked execution ids instead of only latest message id", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java");
assert.match(
source,
/enqueueReplyWaitPoll\(waitSpec,\s*includeDispatchPlans\);/,
"expected reply wait polling to receive the full wait spec, not just a baseline message id",
);
assert.match(
source,
/private void pollUntilReply\(\s*ProjectChatUiState\.ReplyWaitSpec waitSpec,\s*boolean includeDispatchPlans\s*\)/,
"expected pollUntilReply to read the richer wait spec",
);
assert.match(
source,
/ProjectChatUiState\.hasTrackedDispatchExecutionReply\(snapshot\.dispatchPlans,\s*waitSpec\.executionIds\)/,
"expected reply polling to use tracked dispatch executions when waiting on group replies",
);
});

View File

@@ -15,6 +15,8 @@ let completeMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/task
let applyImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/apply/route"))["POST"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
let AUTH_SESSION_COOKIE = "";
async function setup() {
@@ -46,6 +48,8 @@ async function setup() {
applyImportDraftRoute = applyModule.POST;
createAuthSession = data.createAuthSession;
readState = data.readState;
saveAiAccount = data.saveAiAccount;
updateProjectAgentControls = data.updateProjectAgentControls;
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
}
@@ -85,6 +89,25 @@ async function createAuthedRequestFor(
test("device import draft review queues only the resolution task, then completion writes back a ready resolution and apply still works", async () => {
await setup();
await saveAiAccount({
accountId: "master-codex-device-import-policy",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "Mac 上的 Master Codex Node",
nodeId: "local-codex-node",
nodeLabel: "本机 Codex",
model: "gpt-5.4-mini",
enabled: true,
setActive: true,
loginStatusNote: "用于设备导入深度任务模型策略测试。",
});
await updateProjectAgentControls("master-agent", {
fastModelOverride: "gpt-5.4-mini",
fastReasoningEffortOverride: "low",
smartModelOverride: "gpt-5.4",
smartReasoningEffortOverride: "high",
});
const enrollmentResponse = await createEnrollmentRoute(
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
@@ -200,6 +223,8 @@ test("device import draft review queues only the resolution task, then completio
task.status === "queued",
);
assert.ok(resolutionTask, "expected import review to leave a queued master-agent task trace");
assert.equal(resolutionTask?.executionModel, "gpt-5.4");
assert.equal(resolutionTask?.executionReasoningEffort, "high");
const understandingTask = reviewedState.masterAgentTasks.find(
(task) =>
task.taskType === "conversation_reply" &&

View File

@@ -7,6 +7,7 @@ import { NextRequest } from "next/server";
let runtimeRoot = "";
let postMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
let getProjectRoute: (typeof import("../src/app/api/v1/projects/[projectId]/route"))["GET"];
let getDispatchPlansRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/route"))["GET"];
let confirmDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route"))["POST"];
let rejectDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route"))["POST"];
@@ -29,8 +30,9 @@ async function setup() {
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [messageModule, plansModule, confirmModule, rejectModule, retryModule, reminderModule, data, auth] = await Promise.all([
const [messageModule, projectModule, plansModule, confirmModule, rejectModule, retryModule, reminderModule, data, auth] = await Promise.all([
import("../src/app/api/v1/projects/[projectId]/messages/route.ts"),
import("../src/app/api/v1/projects/[projectId]/route.ts"),
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/route.ts"),
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts"),
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route.ts"),
@@ -41,6 +43,7 @@ async function setup() {
]);
postMessageRoute = messageModule.POST;
getProjectRoute = projectModule.GET;
getDispatchPlansRoute = plansModule.GET;
confirmDispatchPlanRoute = confirmModule.POST;
rejectDispatchPlanRoute = rejectModule.POST;
@@ -334,6 +337,234 @@ test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms
assert.equal(executionTask?.orchestrationBackendLabel, "Boss Native Orchestrator");
});
test("GET /api/v1/projects/[projectId]/dispatch-plans includes execution summaries after confirmation", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;
assert.ok(approvedTargetProjectId, "expected a recommended target project");
const confirmResponse = await confirmDispatchPlanRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/confirm`,
"POST",
{ approvedTargetProjectIds: [approvedTargetProjectId] },
),
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
);
assert.equal(confirmResponse.status, 200);
const response = await getDispatchPlansRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans`,
"GET",
),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
plans: Array<{
planId: string;
executions?: Array<{
executionId: string;
targetProjectId: string;
targetThreadId: string;
status: string;
resultMessageId?: string;
}>;
}>;
};
assert.equal(payload.ok, true);
assert.equal(payload.plans[0]?.planId, dispatchPlan.planId);
assert.ok(payload.plans[0]?.executions?.[0], "expected confirmed plan to expose its execution summaries");
assert.equal(payload.plans[0]?.executions?.[0]?.targetProjectId, approvedTargetProjectId);
assert.equal(payload.plans[0]?.executions?.[0]?.status, "queued");
});
test("GET /api/v1/projects/[projectId] includes group dispatch and participant state for the chat surface", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const response = await getProjectRoute(
await createAuthedRequest(`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}`, "GET"),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
dispatchPlans?: Array<{
planId: string;
status?: string;
summary?: string;
targets?: Array<{ projectId: string; threadDisplayName: string }>;
executions?: Array<{ executionId: string; status: string }>;
}>;
participantsPayload?: {
projectId: string;
participants: Array<{ projectId: string; status?: string; canOpenProject?: boolean }>;
repairRequired: boolean;
};
};
assert.equal(payload.ok, true);
assert.equal(payload.dispatchPlans?.[0]?.planId, dispatchPlan.planId);
assert.equal(payload.dispatchPlans?.[0]?.status, "pending_user_confirmation");
assert.ok(payload.dispatchPlans?.[0]?.summary, "expected project detail to include dispatch summary");
assert.ok(payload.dispatchPlans?.[0]?.targets?.length, "expected project detail to include dispatch targets");
assert.equal(payload.dispatchPlans?.[0]?.targets?.[0]?.projectId, dispatchPlan.targets[0]?.projectId);
assert.ok(payload.participantsPayload, "expected project detail to include participantsPayload");
assert.equal(payload.participantsPayload?.projectId, groupProject.id);
assert.equal(payload.participantsPayload?.repairRequired, false);
assert.ok((payload.participantsPayload?.participants.length ?? 0) >= 2);
assert.equal(payload.participantsPayload?.participants[0]?.status, "active");
assert.equal(payload.participantsPayload?.participants[0]?.canOpenProject, true);
});
test("GET /api/v1/projects/[projectId] includes dispatch execution summaries after confirmation", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;
assert.ok(approvedTargetProjectId, "expected a recommended target project");
const confirmResponse = await confirmDispatchPlanRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/confirm`,
"POST",
{ approvedTargetProjectIds: [approvedTargetProjectId] },
),
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
);
assert.equal(confirmResponse.status, 200);
const response = await getProjectRoute(
await createAuthedRequest(`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}`, "GET"),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
dispatchPlans?: Array<{
planId: string;
executions?: Array<{
executionId: string;
targetProjectId: string;
targetThreadId: string;
status: string;
}>;
}>;
};
assert.equal(payload.ok, true);
assert.equal(payload.dispatchPlans?.[0]?.planId, dispatchPlan.planId);
assert.ok(payload.dispatchPlans?.[0]?.executions?.[0], "expected project detail to include confirmed execution summaries");
assert.equal(payload.dispatchPlans?.[0]?.executions?.[0]?.targetProjectId, approvedTargetProjectId);
assert.equal(payload.dispatchPlans?.[0]?.executions?.[0]?.status, "queued");
});
test("GET /api/v1/projects/[projectId] marks invalid group members as repair-required in detail payload", async () => {
const singles = await ensureTwoSingleThreadProjects();
const groupProject = await createProjectGroupChat({
sourceProjectId: singles[0].id,
memberProjectIds: [singles[1].id],
createdBy: "17600003315",
});
const state = await readState();
await writeState({
...state,
projects: state.projects.map((project) =>
project.id === groupProject.id
? {
...project,
groupMembers: [
{
projectId: "master-agent",
deviceId: "mac-studio",
threadId: "master-agent-thread",
threadDisplayName: "主 Agent 汇总",
folderName: "主控线程",
},
],
}
: project,
),
});
const response = await getProjectRoute(
await createAuthedRequest(`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}`, "GET"),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
participantsPayload?: {
repairRequired: boolean;
validParticipantCount: number;
invalidParticipantCount: number;
participants: Array<{ projectId: string; status?: string; canOpenProject?: boolean }>;
};
};
assert.equal(payload.ok, true);
assert.equal(payload.participantsPayload?.repairRequired, true);
assert.equal(payload.participantsPayload?.validParticipantCount, 0);
assert.equal(payload.participantsPayload?.invalidParticipantCount, 1);
assert.equal(payload.participantsPayload?.participants[0]?.projectId, "master-agent");
assert.equal(payload.participantsPayload?.participants[0]?.status, "invalid_target");
assert.equal(payload.participantsPayload?.participants[0]?.canOpenProject, true);
});
test("GET /api/v1/projects/[projectId] marks missing group members as repair-required in detail payload", async () => {
const singles = await ensureTwoSingleThreadProjects();
const groupProject = await createProjectGroupChat({
sourceProjectId: singles[0].id,
memberProjectIds: [singles[1].id],
createdBy: "17600003315",
});
const state = await readState();
await writeState({
...state,
projects: state.projects.map((project) =>
project.id === groupProject.id
? {
...project,
groupMembers: [
{
projectId: "missing-project-1",
deviceId: "mac-studio",
threadId: "missing-thread-1",
threadDisplayName: "丢失线程引用",
folderName: "异常引用",
},
],
}
: project,
),
});
const response = await getProjectRoute(
await createAuthedRequest(`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}`, "GET"),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
participantsPayload?: {
repairRequired: boolean;
validParticipantCount: number;
invalidParticipantCount: number;
participants: Array<{ projectId: string; status?: string; canOpenProject?: boolean }>;
};
};
assert.equal(payload.ok, true);
assert.equal(payload.participantsPayload?.repairRequired, true);
assert.equal(payload.participantsPayload?.validParticipantCount, 0);
assert.equal(payload.participantsPayload?.invalidParticipantCount, 1);
assert.equal(payload.participantsPayload?.participants[0]?.projectId, "missing-project-1");
assert.equal(payload.participantsPayload?.participants[0]?.status, "missing_project");
assert.equal(payload.participantsPayload?.participants[0]?.canOpenProject, false);
});
test("confirming a dispatch plan with rememberLightReminder persists the group reminder preference", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;

View File

@@ -90,6 +90,19 @@ test("listExecutionBackendChoices keeps claw disabled by default", () => {
);
});
test("listExecutionBackendChoices keeps hermes disabled by default", () => {
const backends = listExecutionBackendChoices({
primary: { provider: "master_codex_node", status: "ready" },
backups: [{ provider: "openai_api", status: "ready" }],
requestKind: "master_agent_reply",
});
assert.deepEqual(
backends.map((backend) => backend.backendId),
["master-codex-node", "openai-api"],
);
});
test("selectExecutionBackendForTesting honors an explicit claw request when claw is enabled", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "ready" },
@@ -135,3 +148,49 @@ test("selectExecutionBackendForTesting falls back when claw is requested but una
assert.equal(backend.backendId, "master-codex-node");
});
test("selectExecutionBackendForTesting honors an explicit hermes request when hermes is enabled", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "ready" },
backups: [{ provider: "openai_api", status: "ready" }],
requestKind: "master_agent_reply",
requestedBackendId: "hermes-runtime",
hermes: {
enabled: true,
selectable: true,
availability: {
status: "ready",
selectable: true,
configured: true,
reason: "ready",
reasonLabel: "Hermes Runtime 可用。",
},
supportsKinds: ["master_agent_reply", "thread_reply"],
},
});
assert.equal(backend.backendId, "hermes-runtime");
});
test("selectExecutionBackendForTesting falls back when hermes is requested but unavailable", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "ready" },
backups: [{ provider: "openai_api", status: "ready" }],
requestKind: "master_agent_reply",
requestedBackendId: "hermes-runtime",
hermes: {
enabled: false,
selectable: false,
availability: {
status: "disabled",
selectable: false,
configured: false,
reason: "disabled",
reasonLabel: "Hermes Runtime 当前未启用。",
},
supportsKinds: ["master_agent_reply"],
},
});
assert.equal(backend.backendId, "master-codex-node");
});

View File

@@ -49,6 +49,7 @@ test("ExecutionResult 类型守卫能区分 queued 与 immediate", () => {
status: "completed",
backendId: "openai-api",
output: "done",
sessionId: "session-completed-1",
};
const failed: ExecutionImmediateResult = {
status: "failed",

View File

@@ -39,6 +39,40 @@ test("MemoryResolver 在 master-agent 会话下优先挑当前请求命中的项
assert.equal(resolved.projectMemories[0]?.projectId, "boss-console");
});
test("MemoryResolver 会按请求里的自然语言关键词命中更相关的项目记忆", () => {
const resolved = resolveRelevantMemoriesForTesting({
projectId: "master-agent",
requestText: "继续推进 boss 项目的会话归档逻辑",
memories: [
{
memoryId: "m1",
scope: "project",
projectId: "boss-console",
title: "boss 项目进度",
content: "boss 项目当前按项目聚合加线程下钻展示。",
tags: ["boss", "会话"],
memoryType: "project_progress",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
memoryId: "m2",
scope: "project",
projectId: "project-wenshenapp",
title: "wenshenapp 项目进度",
content: "wenshenapp 当前只有一个主线程。",
tags: ["wenshenapp"],
memoryType: "project_progress",
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
},
],
});
assert.equal(resolved.projectMemories.length, 1);
assert.equal(resolved.projectMemories[0]?.projectId, "boss-console");
});
test("MemoryResolver 会保留全局记忆的输入顺序并只截断到 8 条", () => {
const resolved = resolveRelevantMemoriesForTesting({
projectId: "master-agent",
@@ -115,6 +149,40 @@ test("Runtime MemoryResolver 会优先排布 workflow_rule 和 user_preference
);
});
test("Runtime MemoryResolver 也会按自然语言关键词优先挑中更相关的项目记忆", () => {
const resolved = resolveRuntimeRelevantMemoriesForTesting({
projectId: "master-agent",
requestText: "继续推进 boss 项目的会话归档逻辑",
memories: [
{
memoryId: "m2",
scope: "project",
projectId: "project-wenshenapp",
title: "wenshenapp 项目进度",
content: "wenshenapp 当前只有一个主线程。",
tags: ["wenshenapp"],
memoryType: "project_progress",
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
},
{
memoryId: "m1",
scope: "project",
projectId: "boss-console",
title: "boss 项目进度",
content: "boss 项目当前按项目聚合加线程下钻展示。",
tags: ["boss", "会话"],
memoryType: "project_progress",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
],
});
assert.equal(resolved.projectMemories.length, 1);
assert.equal(resolved.projectMemories[0]?.projectId, "boss-console");
});
test("Runtime MemoryResolver 在 master-agent 非空请求但无 lexical 命中时回退到前 6 个项目记忆", () => {
const resolved = resolveRuntimeRelevantMemoriesForTesting({
projectId: "master-agent",

View File

@@ -9,6 +9,8 @@ let runtimeRoot = "";
let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let createIndependentGroupChat: (typeof import("../src/lib/boss-data"))["createIndependentGroupChat"];
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let AUTH_SESSION_COOKIE: string;
@@ -32,6 +34,8 @@ async function setup() {
POST = routePost;
createAuthSession = data.createAuthSession;
createIndependentGroupChat = data.createIndependentGroupChat;
saveAiAccount = data.saveAiAccount;
updateProjectAgentControls = data.updateProjectAgentControls;
readState = data.readState;
writeState = data.writeState;
baseState = structuredClone(await readState());
@@ -151,6 +155,25 @@ async function ensureTwoSingleThreadProjects() {
test("POST /api/v1/projects/[projectId]/messages returns a dispatch plan for group text messages", async () => {
await setup();
await saveAiAccount({
accountId: "master-codex-smart-policy",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "Mac 上的 Master Codex Node",
nodeId: "local-codex-node",
nodeLabel: "本机 Codex",
model: "gpt-5.4-mini",
enabled: true,
setActive: true,
loginStatusNote: "用于深度任务模型策略测试。",
});
await updateProjectAgentControls("master-agent", {
fastModelOverride: "gpt-5.4-mini",
fastReasoningEffortOverride: "low",
smartModelOverride: "gpt-5.4",
smartReasoningEffortOverride: "high",
});
const memberProjects = await ensureTwoSingleThreadProjects();
assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects");
@@ -199,6 +222,8 @@ test("POST /api/v1/projects/[projectId]/messages returns a dispatch plan for gro
1,
"expected group messages to enqueue a master-agent dispatch recommendation task",
);
assert.equal(queuedGroupDispatchTasks[0]?.executionModel, "gpt-5.4");
assert.equal(queuedGroupDispatchTasks[0]?.executionReasoningEffort, "high");
});
test("POST /api/v1/projects/[projectId]/messages keeps dispatchPlan null for single-thread projects", async () => {

View File

@@ -67,27 +67,49 @@ async function ensureTwoSingleThreadProjects() {
return singles;
}
assert.ok(singles[0], "expected seeded single-thread project");
const seed = singles[0];
const clone = {
...seed,
id: "repair-thread-clone",
name: "Repair Thread Clone",
const generatedProjects = Array.from({ length: 2 - singles.length }, (_, index) => ({
id: `repair-thread-${index + 1}`,
name: `Repair Thread ${index + 1}`,
pinned: false,
systemPinned: false,
deviceIds: ["mac-studio"],
preview: "用于群成员修复 contract 的测试线程。",
updatedAt: "2026-03-30T10:00:00+08:00",
lastMessageAt: "2026-03-30T10:00:00+08:00",
isGroup: false,
threadMeta: {
...seed.threadMeta,
projectId: "repair-thread-clone",
threadId: "repair-thread-clone",
threadDisplayName: "维修回归线程",
projectId: `repair-thread-${index + 1}`,
threadId: `repair-thread-${index + 1}`,
threadDisplayName: `维修回归线程 ${index + 1}`,
folderName: "repair-folder",
codexThreadRef: "repair-thread-clone",
activityIconCount: 0,
updatedAt: "2026-03-30T10:00:00+08:00",
codexThreadRef: `repair-thread-${index + 1}`,
codexFolderRef: "repair-folder",
},
};
groupMembers: [],
createdByAgent: true,
collaborationMode: "development" as const,
approvalState: "not_required" as const,
unreadCount: 0,
riskLevel: "low" as const,
messages: [
{
id: `msg-repair-thread-${index + 1}`,
sender: "device" as const,
senderLabel: "Win GPU / Codex",
body: "用于群成员修复 contract 的测试线程。",
sentAt: "2026-03-30T10:00:00+08:00",
kind: "text" as const,
},
],
goals: [],
versions: [],
}));
await writeState({
...state,
projects: [...state.projects, clone],
projects: [...state.projects, ...generatedProjects],
});
const nextState = await readState();
return nextState.projects.filter((project) => project.id !== "master-agent" && !project.isGroup);
@@ -215,3 +237,28 @@ test("POST /api/v1/projects/[projectId]/participants replaces dirty members with
);
assert.ok(repairNotice, "expected a group repair system notice");
});
test("POST /api/v1/projects/[projectId]/participants maps stale member errors to readable copy", async () => {
await setup();
const singles = await ensureTwoSingleThreadProjects();
const groupProject = await createProjectGroupChat({
sourceProjectId: singles[0].id,
memberProjectIds: [singles[1].id],
createdBy: "17600003315",
});
const response = await updateParticipantsRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/participants`,
"POST",
{ memberProjectIds: [singles[0].id, "missing-thread-project"] },
),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 400);
const payload = (await response.json()) as { ok: boolean; message: string };
assert.equal(payload.ok, false);
assert.equal(payload.message, "有线程已经不存在,请刷新后重新选择。");
assert.notEqual(payload.message, "GROUP_CHAT_MEMBER_NOT_FOUND");
});

View File

@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import test from "node:test";
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import {
getHermesBackendAvailabilityForTesting,
getHermesBackendConfigForTesting,
isHermesBackendConfiguredForTesting,
} from "../src/lib/execution/backends/hermes-config.ts";
function snapshotEnv() {
return {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_WORKDIR: process.env.BOSS_HERMES_WORKDIR,
BOSS_HERMES_TIMEOUT_MS: process.env.BOSS_HERMES_TIMEOUT_MS,
BOSS_HERMES_DEFAULT_MODEL: process.env.BOSS_HERMES_DEFAULT_MODEL,
BOSS_HERMES_TOOLSETS: process.env.BOSS_HERMES_TOOLSETS,
BOSS_HERMES_SKILLS: process.env.BOSS_HERMES_SKILLS,
};
}
function restoreEnv(snapshot: ReturnType<typeof snapshotEnv>) {
process.env.BOSS_HERMES_ENABLED = snapshot.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = snapshot.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = snapshot.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_WORKDIR = snapshot.BOSS_HERMES_WORKDIR;
process.env.BOSS_HERMES_TIMEOUT_MS = snapshot.BOSS_HERMES_TIMEOUT_MS;
process.env.BOSS_HERMES_DEFAULT_MODEL = snapshot.BOSS_HERMES_DEFAULT_MODEL;
process.env.BOSS_HERMES_TOOLSETS = snapshot.BOSS_HERMES_TOOLSETS;
process.env.BOSS_HERMES_SKILLS = snapshot.BOSS_HERMES_SKILLS;
}
test("Hermes backend 在未配置时默认关闭", () => {
const previous = snapshotEnv();
delete process.env.BOSS_HERMES_ENABLED;
delete process.env.BOSS_HERMES_COMMAND;
delete process.env.BOSS_HERMES_ARGS;
delete process.env.BOSS_HERMES_WORKDIR;
delete process.env.BOSS_HERMES_TIMEOUT_MS;
delete process.env.BOSS_HERMES_DEFAULT_MODEL;
delete process.env.BOSS_HERMES_TOOLSETS;
delete process.env.BOSS_HERMES_SKILLS;
const config = getHermesBackendConfigForTesting();
assert.equal(config.enabled, false);
assert.equal(config.command, "hermes");
assert.equal(isHermesBackendConfiguredForTesting(config), false);
restoreEnv(previous);
});
test("Hermes backend 在配置完整时返回 command、args、toolsets 和 skills", () => {
const previous = snapshotEnv();
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = "hermes";
process.env.BOSS_HERMES_ARGS = "--profile prod";
process.env.BOSS_HERMES_WORKDIR = "/tmp/hermes";
process.env.BOSS_HERMES_TIMEOUT_MS = "39000";
process.env.BOSS_HERMES_DEFAULT_MODEL = "gpt-5.4";
process.env.BOSS_HERMES_TOOLSETS = "web,terminal";
process.env.BOSS_HERMES_SKILLS = "boss-dev,github";
const config = getHermesBackendConfigForTesting();
assert.equal(config.enabled, true);
assert.equal(config.command, "hermes");
assert.deepEqual(config.args, ["--profile", "prod"]);
assert.equal(config.cwd, "/tmp/hermes");
assert.equal(config.timeoutMs, 39000);
assert.equal(config.defaultModel, "gpt-5.4");
assert.deepEqual(config.toolsets, ["web", "terminal"]);
assert.deepEqual(config.skills, ["boss-dev", "github"]);
assert.equal(isHermesBackendConfiguredForTesting(config), true);
restoreEnv(previous);
});
test("Hermes backend availability 会在可执行命令和脚本都存在时返回 ready", async () => {
const previous = snapshotEnv();
const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-hermes-config-"));
const scriptPath = path.join(tempDir, "hermes-smoke.mjs");
await writeFile(scriptPath, "console.log('ok');\n", "utf8");
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = scriptPath;
process.env.BOSS_HERMES_WORKDIR = tempDir;
try {
const availability = await getHermesBackendAvailabilityForTesting();
assert.equal(availability.status, "ready");
assert.equal(availability.selectable, true);
assert.equal(availability.reason, "ready");
} finally {
restoreEnv(previous);
await rm(tempDir, { recursive: true, force: true });
}
});
test("Hermes backend availability 会在脚本参数不存在时返回不可选", async () => {
const previous = snapshotEnv();
const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-hermes-config-"));
const missingScript = path.join(tempDir, "missing-hermes-script.mjs");
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = missingScript;
process.env.BOSS_HERMES_WORKDIR = tempDir;
try {
const availability = await getHermesBackendAvailabilityForTesting();
assert.equal(availability.status, "misconfigured");
assert.equal(availability.selectable, false);
assert.equal(availability.reason, "script_not_found");
} finally {
restoreEnv(previous);
await rm(tempDir, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createHermesBackendForTesting } from "../src/lib/execution/backends/hermes-backend.ts";
test("Hermes backend 只在启用且请求类型受支持时 canHandle", async () => {
const backend = createHermesBackendForTesting({
config: {
enabled: true,
command: "hermes",
args: [],
timeoutMs: 45_000,
sourceTag: "tool",
},
runner: async () => ({
status: "completed",
backendId: "hermes-runtime",
output: "ok",
}),
});
assert.equal(
await backend.canHandle({
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: "msg-1",
body: "继续",
}),
true,
);
assert.equal(
await backend.canHandle({
kind: "dispatch_execution",
projectId: "project-1",
requestMessageId: "msg-2",
body: "继续",
}),
false,
);
});
test("Hermes backend 执行时会把 executionPrompt、模型、toolsets 和 skills 交给 runner", async () => {
const calls: unknown[] = [];
const backend = createHermesBackendForTesting({
config: {
enabled: true,
command: "hermes",
args: ["--profile", "prod"],
timeoutMs: 45_000,
defaultModel: "gpt-5.4",
toolsets: ["web", "terminal"],
skills: ["boss-dev"],
sourceTag: "tool",
},
runner: async (input) => {
calls.push(input);
return {
status: "completed",
backendId: "hermes-runtime",
output: "链路正常",
};
},
});
const result = await backend.execute({
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: "msg-1",
body: "继续推进",
executionPrompt: "系统提示词 + 用户提示词 + 当前消息",
modelOverride: "gpt-5.5",
reasoningEffortOverride: "high",
});
assert.equal(result.status, "completed");
assert.deepEqual(calls, [
{
config: {
enabled: true,
command: "hermes",
args: ["--profile", "prod"],
timeoutMs: 45_000,
defaultModel: "gpt-5.4",
toolsets: ["web", "terminal"],
skills: ["boss-dev"],
sourceTag: "tool",
},
payload: {
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: "msg-1",
body: "继续推进",
executionPrompt: "系统提示词 + 用户提示词 + 当前消息",
model: "gpt-5.5",
reasoningEffort: "high",
toolsets: ["web", "terminal"],
skills: ["boss-dev"],
},
},
]);
});
test("Hermes backend describe 返回稳定描述", async () => {
const backend = createHermesBackendForTesting({
config: {
enabled: true,
command: "hermes",
args: [],
timeoutMs: 45_000,
sourceTag: "tool",
},
runner: async () => ({
status: "completed",
backendId: "hermes-runtime",
output: "ok",
}),
});
const description = await backend.describe({
kind: "thread_reply",
projectId: "project-1",
requestMessageId: "msg-1",
body: "继续",
});
assert.deepEqual(description, {
backendId: "hermes-runtime",
label: "Hermes Runtime",
mode: "local",
});
});

157
tests/hermes-runner.test.ts Normal file
View File

@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import { mkdtemp, realpath, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { runHermesCommandForTesting } from "../src/lib/execution/backends/hermes-runner.ts";
async function createTempScript(source: string) {
const dir = await mkdtemp(join(tmpdir(), "hermes-runner-"));
const scriptPath = join(dir, "hermes-script.mjs");
await writeFile(scriptPath, source, "utf8");
return { dir, scriptPath };
}
test("Hermes runner 会按固定 chat -q -Q 形态执行并提取正文", async () => {
const workspace = await mkdtemp(join(tmpdir(), "hermes-runner-cwd-"));
const expectedWorkspace = await realpath(workspace);
const { scriptPath } = await createTempScript(`
process.stdout.write(JSON.stringify({
argv: process.argv.slice(2),
cwd: process.cwd(),
envSource: process.env.HERMES_SESSION_SOURCE || ""
}) + "\\n");
process.stdout.write("Hermes smoke completed\\n\\n");
process.stdout.write("session_id: hermes-session-123\\n");
`);
const result = await runHermesCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
cwd: workspace,
timeoutMs: 1000,
defaultModel: "gpt-5.4",
toolsets: ["web", "terminal"],
skills: ["boss-dev"],
sourceTag: "tool",
},
payload: {
executionPrompt: "请输出链路正常",
model: "gpt-5.5",
},
});
assert.equal(result.status, "completed");
if (result.status !== "completed") {
assert.fail("expected completed");
}
const lines = result.output.split("\n");
const metadata = JSON.parse(lines[0] ?? "{}") as {
argv: string[];
cwd: string;
envSource: string;
};
assert.deepEqual(metadata.argv, [
"chat",
"-q",
"请输出链路正常",
"-Q",
"--source",
"tool",
"-m",
"gpt-5.5",
"-t",
"web,terminal",
"-s",
"boss-dev",
]);
assert.equal(metadata.cwd, expectedWorkspace);
assert.equal(metadata.envSource, "");
assert.equal(lines.at(-1), "Hermes smoke completed");
assert.equal(result.sessionId, "hermes-session-123");
});
test("Hermes runner 会把非零退出码映射成 stderr 或退出码错误", async () => {
const { scriptPath } = await createTempScript(`
process.stderr.write("hermes crashed");
process.exit(2);
`);
const result = await runHermesCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 1000,
sourceTag: "tool",
},
payload: {
executionPrompt: "anything",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.match(result.error, /hermes crashed/);
});
test("Hermes runner 在输出只有 session_id 时会视为失败", async () => {
const { scriptPath } = await createTempScript(`
process.stdout.write("session_id: hermes-session-123\\n");
`);
const result = await runHermesCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 1000,
sourceTag: "tool",
},
payload: {
executionPrompt: "anything",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.match(result.error, /EMPTY_HERMES_RESPONSE/);
});
test("Hermes runner 超时后返回 HERMES_TIMEOUT", async () => {
const { scriptPath } = await createTempScript(`
setTimeout(() => {
process.stdout.write("late response\\n");
}, 500);
`);
const result = await runHermesCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 50,
sourceTag: "tool",
},
payload: {
executionPrompt: "slow",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.match(result.error, /HERMES_TIMEOUT/);
});

View File

@@ -169,6 +169,38 @@ test("master agent reply without target thread stays on ephemeral exec", () => {
]);
});
test("task execution model overrides local-agent default model", () => {
const execution = buildCodexTaskExecution(
{
masterAgentWorkdir: "/Users/kris/code/boss",
masterAgentSandbox: "workspace-write",
masterAgentModel: "gpt-5.4-mini",
},
{
taskType: "group_dispatch_plan",
executionPrompt: "请生成群聊分发方案",
executionModel: "gpt-5.4",
},
"/tmp/master.txt",
);
assert.equal(execution.mode, "ephemeral");
assert.deepEqual(execution.args, [
"exec",
"--ephemeral",
"--skip-git-repo-check",
"-C",
"/Users/kris/code/boss",
"-s",
"workspace-write",
"-o",
"/tmp/master.txt",
"-m",
"gpt-5.4",
"请生成群聊分发方案",
]);
});
test("conversation reply preflight fails closed when target cwd is missing", async () => {
const missingFolder = "/tmp/boss-local-agent-missing-workdir";
const stateDbPath = await createCodexStateDb([

View File

@@ -12,6 +12,7 @@ let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
let getProjectAgentControls: (typeof import("../src/lib/boss-data"))["getProjectAgentControls"];
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
let getProjectDetailView: (typeof import("../src/lib/boss-projections"))["getProjectDetailView"];
let getProjectRoute: (typeof import("../src/app/api/v1/projects/[projectId]/route"))["GET"];
let getAgentControlsRoute: (typeof import("../src/app/api/v1/projects/[projectId]/agent-controls/route"))["GET"];
@@ -39,6 +40,7 @@ async function setup() {
writeState = data.writeState;
updateProjectAgentControls = data.updateProjectAgentControls;
getProjectAgentControls = data.getProjectAgentControls;
saveAiAccount = data.saveAiAccount;
getProjectDetailView = projections.getProjectDetailView;
getProjectRoute = projectRouteModule.GET;
getAgentControlsRoute = agentControlsRouteModule.GET;
@@ -115,20 +117,32 @@ test("master-agent 会话可保存并读取模型与推理强度覆盖", async (
await updateProjectAgentControls("master-agent", {
modelOverride: "gpt-5.4",
reasoningEffortOverride: "high",
fastModelOverride: "gpt-5.4-mini",
fastReasoningEffortOverride: "low",
smartModelOverride: "gpt-5.4",
smartReasoningEffortOverride: "high",
});
const controls = await getProjectAgentControls("master-agent");
assert.equal(controls?.modelOverride, "gpt-5.4");
assert.equal(controls?.reasoningEffortOverride, "high");
assert.equal(controls?.fastModelOverride, "gpt-5.4-mini");
assert.equal(controls?.fastReasoningEffortOverride, "low");
assert.equal(controls?.smartModelOverride, "gpt-5.4");
assert.equal(controls?.smartReasoningEffortOverride, "high");
const state = await readState();
const project = state.projects.find((item) => item.id === "master-agent");
assert.equal(project?.agentControls?.modelOverride, "gpt-5.4");
assert.equal(project?.agentControls?.reasoningEffortOverride, "high");
assert.equal(project?.agentControls?.fastModelOverride, "gpt-5.4-mini");
assert.equal(project?.agentControls?.fastReasoningEffortOverride, "low");
const detail = getProjectDetailView(state, "master-agent");
assert.equal(detail?.agentControls?.modelOverride, "gpt-5.4");
assert.equal(detail?.agentControls?.reasoningEffortOverride, "high");
assert.equal(detail?.agentControls?.smartModelOverride, "gpt-5.4");
assert.equal(detail?.agentControls?.smartReasoningEffortOverride, "high");
});
test("master-agent 对话控制路由可读写并回显到项目详情", async () => {
@@ -167,6 +181,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
body: JSON.stringify({
modelOverride: "gpt-5.4",
reasoningEffortOverride: "medium",
fastModelOverride: "gpt-5.4-mini",
fastReasoningEffortOverride: "low",
smartModelOverride: "gpt-5.4",
smartReasoningEffortOverride: "high",
backendOverride: "claw-runtime",
}),
}),
@@ -179,6 +197,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
controls: {
modelOverride?: string;
reasoningEffortOverride?: string;
fastModelOverride?: string;
fastReasoningEffortOverride?: string;
smartModelOverride?: string;
smartReasoningEffortOverride?: string;
backendOverride?: string;
updatedAt: string;
} | null;
@@ -186,6 +208,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
assert.equal(postPayload.ok, true);
assert.equal(postPayload.controls?.modelOverride, "gpt-5.4");
assert.equal(postPayload.controls?.reasoningEffortOverride, "medium");
assert.equal(postPayload.controls?.fastModelOverride, "gpt-5.4-mini");
assert.equal(postPayload.controls?.fastReasoningEffortOverride, "low");
assert.equal(postPayload.controls?.smartModelOverride, "gpt-5.4");
assert.equal(postPayload.controls?.smartReasoningEffortOverride, "high");
assert.equal(postPayload.controls?.backendOverride, "claw-runtime");
const getResponse = await getAgentControlsRoute(
@@ -202,6 +228,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
controls: {
modelOverride?: string;
reasoningEffortOverride?: string;
fastModelOverride?: string;
fastReasoningEffortOverride?: string;
smartModelOverride?: string;
smartReasoningEffortOverride?: string;
backendOverride?: string;
updatedAt: string;
} | null;
@@ -209,6 +239,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
assert.equal(getPayload.ok, true);
assert.equal(getPayload.controls?.modelOverride, "gpt-5.4");
assert.equal(getPayload.controls?.reasoningEffortOverride, "medium");
assert.equal(getPayload.controls?.fastModelOverride, "gpt-5.4-mini");
assert.equal(getPayload.controls?.fastReasoningEffortOverride, "low");
assert.equal(getPayload.controls?.smartModelOverride, "gpt-5.4");
assert.equal(getPayload.controls?.smartReasoningEffortOverride, "high");
assert.equal(getPayload.controls?.backendOverride, "claw-runtime");
const projectResponse = await getProjectRoute(
@@ -225,6 +259,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
agentControls: {
modelOverride?: string;
reasoningEffortOverride?: string;
fastModelOverride?: string;
fastReasoningEffortOverride?: string;
smartModelOverride?: string;
smartReasoningEffortOverride?: string;
backendOverride?: string;
updatedAt: string;
} | null;
@@ -232,6 +270,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
assert.equal(projectPayload.ok, true);
assert.equal(projectPayload.agentControls?.modelOverride, "gpt-5.4");
assert.equal(projectPayload.agentControls?.reasoningEffortOverride, "medium");
assert.equal(projectPayload.agentControls?.fastModelOverride, "gpt-5.4-mini");
assert.equal(projectPayload.agentControls?.fastReasoningEffortOverride, "low");
assert.equal(projectPayload.agentControls?.smartModelOverride, "gpt-5.4");
assert.equal(projectPayload.agentControls?.smartReasoningEffortOverride, "high");
assert.equal(projectPayload.agentControls?.backendOverride, "claw-runtime");
} finally {
if (previousEnv.BOSS_CLAW_ENABLED === undefined) delete process.env.BOSS_CLAW_ENABLED;
@@ -246,6 +288,154 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
}
});
test("master-agent 对话控制路由可读写 Hermes backendOverride", async () => {
await setup();
const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-hermes-agent-controls-"));
const scriptPath = path.join(tempDir, "hermes-runtime.mjs");
await writeFile(scriptPath, "console.log('ok');\n", "utf8");
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_WORKDIR: process.env.BOSS_HERMES_WORKDIR,
};
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = scriptPath;
process.env.BOSS_HERMES_WORKDIR = tempDir;
try {
const session = await createAuthSession({
account: "17600003315",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
const headers = {
"content-type": "application/json",
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
};
const postResponse = await postAgentControlsRoute(
new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", {
method: "POST",
headers,
body: JSON.stringify({
backendOverride: "hermes-runtime",
}),
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(postResponse.status, 200);
const postPayload = (await postResponse.json()) as {
ok: boolean;
controls: {
backendOverride?: string;
} | null;
hermesAvailability?: {
selectable?: boolean;
};
};
assert.equal(postPayload.ok, true);
assert.equal(postPayload.controls?.backendOverride, "hermes-runtime");
assert.equal(postPayload.hermesAvailability?.selectable, true);
const controls = await getProjectAgentControls("master-agent", "17600003315");
assert.equal(controls?.backendOverride, "hermes-runtime");
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_WORKDIR = previousEnv.BOSS_HERMES_WORKDIR;
await rm(tempDir, { recursive: true, force: true });
}
});
test("普通线程对话控制路由可读写 Hermes backendOverride", async () => {
await setup();
const projectId = await ensureOrdinaryProject("ordinary-hermes-project");
const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-hermes-thread-controls-"));
const scriptPath = path.join(tempDir, "hermes-runtime.mjs");
await writeFile(scriptPath, "console.log('ok');\n", "utf8");
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_WORKDIR: process.env.BOSS_HERMES_WORKDIR,
};
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = scriptPath;
process.env.BOSS_HERMES_WORKDIR = tempDir;
try {
const session = await createAuthSession({
account: "17600003315",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
const headers = {
"content-type": "application/json",
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
};
const postResponse = await postAgentControlsRoute(
new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}/agent-controls`, {
method: "POST",
headers,
body: JSON.stringify({
backendOverride: "hermes-runtime",
}),
}),
{ params: Promise.resolve({ projectId }) },
);
assert.equal(postResponse.status, 200);
const postPayload = (await postResponse.json()) as {
ok: boolean;
controls: {
backendOverride?: string;
} | null;
hermesAvailability?: {
selectable?: boolean;
};
};
assert.equal(postPayload.ok, true);
assert.equal(postPayload.controls?.backendOverride, "hermes-runtime");
assert.equal(postPayload.hermesAvailability?.selectable, true);
const controls = await getProjectAgentControls(projectId, "17600003315");
assert.equal(controls?.backendOverride, "hermes-runtime");
const projectResponse = await getProjectRoute(
new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}`, {
method: "GET",
headers,
}),
{ params: Promise.resolve({ projectId }) },
);
assert.equal(projectResponse.status, 200);
const projectPayload = (await projectResponse.json()) as {
ok: boolean;
agentControls: {
backendOverride?: string;
} | null;
};
assert.equal(projectPayload.ok, true);
assert.equal(projectPayload.agentControls?.backendOverride, "hermes-runtime");
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_WORKDIR = previousEnv.BOSS_HERMES_WORKDIR;
await rm(tempDir, { recursive: true, force: true });
}
});
test("master-agent 对话控制按当前账号隔离,不会串到其他用户", async () => {
await setup();
@@ -357,6 +547,78 @@ test("master-agent 对话控制路由单字段更新不会清掉另一字段", a
assert.equal(payload.controls?.reasoningEffortOverride, "low");
});
test("master-agent 对话控制 GET 会返回当前可用模型与预设模型清单", async () => {
await setup();
await saveAiAccount({
accountId: "openai-model-catalog",
label: "OpenAI 主账号",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主账号",
model: "gpt-5.4-mini",
apiKey: "sk-openai-model-catalog",
enabled: true,
setActive: true,
loginStatusNote: "用于模型目录测试。",
});
await saveAiAccount({
accountId: "qwen-model-catalog",
label: "Qwen 备用",
role: "backup",
provider: "aliyun_qwen_api",
displayName: "Qwen 备用",
model: "qwen3.5-plus",
apiKey: "sk-qwen-model-catalog",
enabled: true,
setActive: false,
loginStatusNote: "用于模型目录测试。",
});
const session = await createAuthSession({
account: "17600003315",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
const response = await getAgentControlsRoute(
new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", {
method: "GET",
headers: {
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
},
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
modelCatalog?: {
availableModels?: string[];
selectableModels?: string[];
presetModels?: string[];
};
};
assert.equal(payload.ok, true);
assert.deepEqual(payload.modelCatalog?.availableModels, ["gpt-5.4-mini", "qwen3.5-plus"]);
assert.ok(payload.modelCatalog?.selectableModels?.includes("gpt-5.4"));
assert.ok(payload.modelCatalog?.selectableModels?.includes("gpt-5.4-mini"));
assert.ok(payload.modelCatalog?.selectableModels?.includes("gpt-4.1"));
assert.ok(payload.modelCatalog?.selectableModels?.includes("gpt-4.1-mini"));
assert.ok(payload.modelCatalog?.selectableModels?.includes("qwen3.5-plus"));
assert.deepEqual(payload.modelCatalog?.presetModels, [
"gpt-5.4",
"gpt-5.4-mini",
"gpt-4.1",
"gpt-4.1-mini",
"qwen3.5-plus",
]);
});
test("全局接管默认会透传到普通线程会话详情", async () => {
await setup();
const projectId = await ensureOrdinaryProject("ordinary-takeover-project");

View File

@@ -87,6 +87,62 @@ test("当前对话 override 优先于主控账号默认值", async () => {
assert.equal(resolved.account.model, "gpt-4.1-mini");
});
test("主 Agent 模型策略会按聊天与深度任务选择不同默认模型", async () => {
await saveAiAccount({
accountId: "master-codex-primary",
label: "主 GPT",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主控",
model: "gpt-5.4",
apiKey: "sk-test-master-agent-policy",
enabled: true,
setActive: true,
loginStatusNote: "用于模型策略测试。",
});
await updateProjectAgentControls("master-agent", {
fastModelOverride: "gpt-5.4-mini",
fastReasoningEffortOverride: "low",
smartModelOverride: "gpt-5.4",
smartReasoningEffortOverride: "high",
});
const chatResolved = await resolveMasterAgentExecutionConfig(
"master-agent",
"17600003315",
"帮我看一下当前状态",
"chat",
);
assert.equal(chatResolved.model, "gpt-5.4-mini");
assert.equal(chatResolved.reasoningEffort, "low");
assert.equal(chatResolved.modelPolicy.mode, "fast");
const deepResolved = await resolveMasterAgentExecutionConfig(
"master-agent",
"17600003315",
"深度理解当前项目进度",
"deep_task",
);
assert.equal(deepResolved.model, "gpt-5.4");
assert.equal(deepResolved.reasoningEffort, "high");
assert.equal(deepResolved.modelPolicy.mode, "smart");
await updateProjectAgentControls("master-agent", {
modelOverride: "gpt-4.1",
reasoningEffortOverride: "medium",
});
const forcedResolved = await resolveMasterAgentExecutionConfig(
"master-agent",
"17600003315",
"深度理解当前项目进度",
"deep_task",
);
assert.equal(forcedResolved.model, "gpt-4.1");
assert.equal(forcedResolved.reasoningEffort, "medium");
assert.equal(forcedResolved.modelPolicy.mode, "manual_override");
});
test("主 Agent 执行配置会合成管理员提示词、用户提示词和当前对话提示词", async () => {
await saveAiAccount({
accountId: "master-codex-primary",

View File

@@ -8,6 +8,7 @@ import { NextRequest } from "next/server";
let runtimeRoot = "";
let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
let getProjectAgentControls: (typeof import("../src/lib/boss-data"))["getProjectAgentControls"];
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
@@ -30,6 +31,7 @@ async function setup() {
POST = messageRoute.POST;
saveAiAccount = data.saveAiAccount;
getProjectAgentControls = data.getProjectAgentControls;
updateProjectAgentControls = data.updateProjectAgentControls;
readState = data.readState;
createAuthSession = data.createAuthSession;
@@ -77,6 +79,240 @@ test.beforeEach(async () => {
await mkdir(runtimeRoot, { recursive: true });
});
test("master-agent 明确查询可用模型时直接本地返回模型清单而不进入异步队列", async () => {
await saveAiAccount({
accountId: "openai-model-list",
label: "OpenAI 主账号",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主账号",
model: "gpt-5.4",
apiKey: "sk-openai-model-list",
enabled: true,
setActive: true,
loginStatusNote: "用于模型清单测试。",
});
await saveAiAccount({
accountId: "qwen-model-list",
label: "Qwen 备用",
role: "backup",
provider: "aliyun_qwen_api",
displayName: "阿里百炼",
model: "qwen3.5-plus",
apiKey: "sk-qwen-model-list",
enabled: true,
setActive: false,
loginStatusNote: "用于模型清单测试。",
});
const response = await POST(
await createAuthedRequest("master-agent", {
body: "主 Agent现在有哪些模型可以用",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected the master-agent model list reply to be persisted");
assert.match(reply?.body ?? "", /当前可用模型/);
assert.match(reply?.body ?? "", /gpt-5\.4/);
assert.match(reply?.body ?? "", /qwen3\.5-plus/);
});
test("master-agent 明确要求切快模型时直接更新 controls 并返回完成态", async () => {
await saveAiAccount({
accountId: "openai-fast-switch",
label: "OpenAI 快模型",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 快模型",
model: "gpt-5.4-mini",
apiKey: "sk-openai-fast-switch",
enabled: true,
setActive: true,
loginStatusNote: "用于快模型切换测试。",
});
const response = await POST(
await createAuthedRequest("master-agent", {
body: "帮我把快模型切到 gpt-5.4-mini",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const controls = await getProjectAgentControls("master-agent", "17600003315");
assert.equal(controls?.fastModelOverride ?? null, "gpt-5.4-mini");
const state = await readState();
assert.equal(state.masterAgentTasks.length, 0);
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected the master-agent model switch reply to be persisted");
assert.match(reply?.body ?? "", /快模型/);
assert.match(reply?.body ?? "", /gpt-5\.4-mini/);
assert.equal(reply?.senderLabel ?? "", "主Agent·gpt-5.4-mini");
});
test("master-agent 识别自然写法的模型名并切当前主模型", async () => {
await saveAiAccount({
accountId: "openai-main-switch",
label: "OpenAI 主模型",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主模型",
model: "gpt-5.4",
apiKey: "sk-openai-main-switch",
enabled: true,
setActive: true,
loginStatusNote: "用于主模型自然写法切换测试。",
});
const response = await POST(
await createAuthedRequest("master-agent", {
body: "把主agent模型换成gpt5.4",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const controls = await getProjectAgentControls("master-agent", "17600003315");
assert.equal(controls?.modelOverride ?? null, "gpt-5.4");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected the master-agent natural model switch reply to be persisted");
assert.match(reply?.body ?? "", /当前主模型/);
assert.match(reply?.body ?? "", /gpt-5\.4/);
assert.equal(reply?.senderLabel ?? "", "主Agent·gpt-5.4");
});
test("master-agent 查询当前是什么大模型时直接走 fast path 返回当前模型摘要", async () => {
await saveAiAccount({
accountId: "openai-fast-query",
label: "OpenAI 主模型",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主模型",
model: "gpt-5.4",
apiKey: "sk-openai-fast-query",
enabled: true,
setActive: true,
loginStatusNote: "用于当前模型查询测试。",
});
await updateProjectAgentControls(
"master-agent",
{
fastModelOverride: "gpt-5.4-mini",
smartModelOverride: "gpt-5.4",
},
"17600003315",
);
const response = await POST(
await createAuthedRequest("master-agent", {
body: "你现在是什么大模型",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected the master-agent fast model summary reply to be persisted");
assert.match(reply?.body ?? "", /当前聊天模型gpt-5\.4-mini/);
assert.match(reply?.body ?? "", /强模型gpt-5\.4/);
assert.equal(reply?.senderLabel ?? "", "主Agent·gpt-5.4-mini");
});
test("master-agent 查询当前后端时直接走 fast path 返回后端摘要", async () => {
await saveAiAccount({
accountId: "openai-backend-query",
label: "OpenAI 主模型",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主模型",
model: "gpt-5.4",
apiKey: "sk-openai-backend-query",
enabled: true,
setActive: true,
loginStatusNote: "用于后端查询测试。",
});
await updateProjectAgentControls(
"master-agent",
{
backendOverride: "hermes-runtime",
},
"17600003315",
);
const response = await POST(
await createAuthedRequest("master-agent", {
body: "当前后端是什么",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected the master-agent backend summary reply to be persisted");
assert.match(reply?.body ?? "", /当前后端hermes-runtime/);
assert.equal(reply?.senderLabel ?? "", "主Agent·gpt-5.4");
});
test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异步实际回复时继承当前会话覆盖", async () => {
await saveAiAccount({
accountId: "openai-master-agent-queue",
@@ -122,6 +358,7 @@ test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异
const payload = (await response.json()) as {
ok: boolean;
message: { id: string };
task?: { taskId: string; taskType: string; status: string } | null;
masterReplyState?: "queued" | "running" | "completed";
masterReply?: { accountId?: string } | null;
@@ -134,6 +371,7 @@ test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异
assert.equal(payload.task?.taskType, "conversation_reply");
assert.equal(payload.task?.status, "queued");
assert.ok(payload.task?.taskId, "expected a stable taskId in the response");
assert.equal((payload.task as { requestMessageId?: string } | null)?.requestMessageId, payload.message.id);
await waitFor(async () => {
const state = await readState();
@@ -333,6 +571,96 @@ test("master-agent enqueue 在显式选择 claw-runtime 时会通过 Claw 异步
}
});
test("master-agent enqueue 在显式选择 hermes-runtime 时会通过 Hermes 异步回写回复", async () => {
const hermesDir = await mkdtemp(path.join(os.tmpdir(), "boss-hermes-queue-"));
const hermesScriptPath = path.join(hermesDir, "hermes-runtime.mjs");
await writeFile(
hermesScriptPath,
`
const args = process.argv.slice(2);
const queryIndex = args.findIndex((item) => item === "-q" || item === "--query");
const query = queryIndex >= 0 ? args[queryIndex + 1] ?? "" : "";
process.stdout.write("Hermes 已接管当前主 Agent 会话:" + query + "\\n\\n");
process.stdout.write("session_id: hermes-session-123\\n");
`,
"utf8",
);
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_TIMEOUT_MS: process.env.BOSS_HERMES_TIMEOUT_MS,
};
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = hermesScriptPath;
process.env.BOSS_HERMES_TIMEOUT_MS = "1000";
await saveAiAccount({
accountId: "master-codex-primary-hermes",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "Mac 上的 Master Codex Node",
nodeId: "local-codex-node",
nodeLabel: "本机 Codex",
model: "gpt-5.4",
enabled: true,
setActive: true,
loginStatusNote: "用于 Hermes backend 队列测试。",
});
await updateProjectAgentControls("master-agent", {
backendOverride: "hermes-runtime",
});
try {
const response = await POST(
await createAuthedRequest("master-agent", {
body: "请走 Hermes runtime",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string; status: string } | null;
masterReply?: { accountId?: string } | null;
masterReplyState?: string | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.masterReply?.accountId, "hermes-runtime");
assert.equal(payload.masterReplyState, "queued");
assert.ok(payload.task?.taskId);
await waitFor(async () => {
const state = await readState();
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
return task?.status === "completed";
});
const nextState = await readState();
const task = nextState.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
assert.equal(task?.status, "completed");
assert.match(task?.replyBody ?? "", /Hermes 已接管当前主 Agent 会话:/);
assert.match(task?.replyBody ?? "", /请走 Hermes runtime/);
assert.equal(task?.sessionId, "hermes-session-123");
const masterProject = nextState.projects.find((project) => project.id === "master-agent");
const mirroredReply = masterProject?.messages.at(-1);
assert.match(mirroredReply?.body ?? "", /Hermes 已接管当前主 Agent 会话/);
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_TIMEOUT_MS = previousEnv.BOSS_HERMES_TIMEOUT_MS;
await rm(hermesDir, { recursive: true, force: true });
}
});
test("master-agent enqueue 在首选主节点离线时会回退到可用的备用主节点并返回实际账号", async () => {
await saveAiAccount({
accountId: "master-codex-primary-offline",

View File

@@ -153,6 +153,19 @@ test("master-agent 记忆页会返回当前用户所有项目记忆", async () =
}),
}),
);
await getUserMasterMemoriesRoute.POST(
new NextRequest("http://127.0.0.1:3000/api/v1/master-agent/memories", {
method: "POST",
headers: adminRequest.headers,
body: JSON.stringify({
scope: "project",
projectId: "boss-console",
title: "Boss 进度",
content: "Boss 项目聊天主链已接通。",
memoryType: "project_progress",
}),
}),
);
await getUserMasterMemoriesRoute.POST(
new NextRequest("http://127.0.0.1:3000/api/v1/master-agent/memories", {
method: "POST",
@@ -183,7 +196,7 @@ test("master-agent 记忆页会返回当前用户所有项目记忆", async () =
assert.equal(payload.ok, true);
assert.deepEqual(
payload.memories.project.map((memory) => memory.projectId).sort(),
["boss-console", "master-agent", "wenshenapp"].sort(),
["master-agent", "wenshenapp"].sort(),
);
});
@@ -276,3 +289,37 @@ test("prompt-profile 会返回当前 Claw Runtime 的可用性状态", async ()
reasonLabel: "Claw Runtime 当前未启用。",
});
});
test("prompt-profile 会返回当前 Hermes Runtime 的可用性状态", async () => {
await setup();
const memberRequest = await createAuthedRequest("18800001111", "member");
const response = await promptProfileRoute.GET(
new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/prompt-profile", {
method: "GET",
headers: memberRequest.headers,
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
hermesAvailability?: {
configured: boolean;
status: string;
selectable: boolean;
reason: string;
reasonLabel: string;
};
};
assert.equal(payload.ok, true);
assert.deepEqual(payload.hermesAvailability, {
command: "hermes",
configured: false,
status: "disabled",
selectable: false,
reason: "disabled",
reasonLabel: "Hermes Runtime 当前未启用。",
});
});

View File

@@ -14,6 +14,8 @@ let listUserMasterMemories: (typeof import("../src/lib/boss-data"))["listUserMas
let createUserMasterMemory: (typeof import("../src/lib/boss-data"))["createUserMasterMemory"];
let updateUserMasterMemory: (typeof import("../src/lib/boss-data"))["updateUserMasterMemory"];
let archiveUserMasterMemory: (typeof import("../src/lib/boss-data"))["archiveUserMasterMemory"];
let stateFile = "";
let stateBackupFile = "";
async function setup() {
if (runtimeRoot) return;
@@ -21,6 +23,8 @@ async function setup() {
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-prompts-memory-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
stateFile = process.env.BOSS_STATE_FILE;
stateBackupFile = `${stateFile}.bak`;
const data = await import("../src/lib/boss-data.ts");
readState = data.readState;
@@ -34,6 +38,21 @@ async function setup() {
archiveUserMasterMemory = data.archiveUserMasterMemory;
}
async function resetState() {
if (!stateFile) {
return;
}
await Promise.all([
rm(stateFile, { force: true }),
rm(stateBackupFile, { force: true }),
]);
}
test.beforeEach(async () => {
await setup();
await resetState();
});
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
@@ -85,3 +104,27 @@ test("主 Agent 提示词与用户记忆可读写", async () => {
assert.equal(archived?.archived, true);
assert.equal((await readState()).masterAgentMemories.length, 1);
});
test("主 Agent 项目记忆不会在状态归一化时误删 boss-console 作用域", async () => {
await setup();
const created = await createUserMasterMemory({
account: "17600003315",
scope: "project",
projectId: "boss-console",
title: "boss 项目进度",
content: "boss 项目当前按项目聚合加线程下钻展示。",
memoryType: "project_progress",
tags: ["boss", "会话"],
});
assert.equal(created.projectId, "boss-console");
const all = await listUserMasterMemories("17600003315", { includeArchived: true });
assert.equal(all.length, 1);
assert.equal(all[0]?.projectId, "boss-console");
const state = await readState();
assert.equal(state.masterAgentMemories.length, 1);
assert.equal(state.masterAgentMemories[0]?.projectId, "boss-console");
});

View File

@@ -17,4 +17,69 @@ test("project chat page listens to conversation updates for realtime refresh", a
/"conversation\.updated"/,
"expected project chat page to refresh when conversation.updated is emitted",
);
assert.match(
source,
/const warningMap = new Map<string, typeof detail\.executionWarnings\[number\]>\(\);/,
"expected project chat page to build a per-message warning map from executionWarnings",
);
assert.match(
source,
/detail\.conversationTasks\.find\(\(task\) => task\.requestMessageId === message\.id\)/,
"expected project chat page to bind lightweight conversation tasks to each message",
);
assert.match(
source,
/messageTask \? \(/,
"expected project chat page to render a compact per-message task status strip",
);
assert.match(
source,
/new Map<string, typeof detail\.executionWarnings\[number\]>\(\)/,
"expected project chat page to dedupe repeated warnings per message before rendering",
);
assert.match(
source,
/dedupedWarnings\.map\(\(warning\) => \(/,
"expected project chat page to render deduped warnings instead of the raw warning list",
);
assert.match(
source,
/detail\.conversationTasks\.length \?/,
"expected project chat page to keep a task status summary when lightweight conversation tasks exist",
);
assert.match(
source,
/resolveDispatchPlanComposerState\(detail\.dispatchPlans\)/,
"expected project chat page to derive dispatch plan composer state directly from project detail payload",
);
assert.doesNotMatch(
source,
/listDispatchPlansByProject/,
"expected project chat page to avoid a separate dispatch plan read outside project detail payload",
);
assert.match(
source,
/detail\.participantsPayload && detail\.participantsPayload\.repairRequired/,
"expected project chat page to surface a repair card when group participants are invalid",
);
assert.match(
source,
/修复群成员/,
"expected project chat page to show a visible repair members affordance",
);
assert.match(
source,
/detail\.participantsPayload\.repairReason/,
"expected project chat page to render the server-provided repair reason copy",
);
assert.match(
source,
/detail\.participantsPayload\.participants\.filter\(\(participant\) => participant\.status !== "active"\)/,
"expected project chat page to surface the concrete invalid group members instead of only a generic repair flag",
);
assert.match(
source,
/participant\.statusLabel \?\? participant\.status/,
"expected project chat page to show each invalid participant status label",
);
});

View File

@@ -0,0 +1,260 @@
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";
import { NextRequest } from "next/server";
let runtimeRoot = "";
let getProjectRoute: (typeof import("../src/app/api/v1/projects/[projectId]/route"))["GET"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let AUTH_SESSION_COOKIE = "";
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-project-detail-route-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [projectRouteModule, data, auth] = await Promise.all([
import("../src/app/api/v1/projects/[projectId]/route.ts"),
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
]);
getProjectRoute = projectRouteModule.GET;
createAuthSession = data.createAuthSession;
readState = data.readState;
writeState = data.writeState;
baseState = structuredClone(await readState());
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test.beforeEach(async () => {
await setup();
await writeState(structuredClone(baseState));
});
function buildSingleThreadProject(projectId: string) {
return {
id: projectId,
name: "项目详情线程",
pinned: false,
systemPinned: false,
deviceIds: ["device-project-detail"],
preview: "等待详情刷新。",
updatedAt: "2026-04-14T14:00:00+08:00",
lastMessageAt: "2026-04-14T14:00:00+08:00",
isGroup: false,
threadMeta: {
projectId,
threadId: "thread-project-detail",
threadDisplayName: "项目详情线程",
folderName: "Boss",
activityIconCount: 0,
updatedAt: "2026-04-14T14:00:00+08:00",
codexThreadRef: "thread-project-detail",
codexFolderRef: "boss",
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development" as const,
approvalState: "not_required" as const,
unreadCount: 0,
riskLevel: "low" as const,
messages: [
{
id: "project-detail-message-1",
sender: "assistant",
senderLabel: "Codex",
body: "项目详情页需要展示任务状态。",
kind: "text" as const,
sentAt: "2026-04-14T14:00:00+08:00",
},
],
goals: [],
versions: [],
};
}
async function createAuthedRequest(projectId: string) {
const session = await createAuthSession({
account: "17600003315",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
return new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}`, {
method: "GET",
headers: {
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
},
});
}
test("GET /api/v1/projects/[projectId] includes lightweight conversation tasks and execution warnings", async () => {
await setup();
const state = await readState();
const project = buildSingleThreadProject("project-detail");
await writeState({
...state,
devices: state.devices.concat({
id: "device-project-detail",
name: "Mac Studio",
avatar: "M",
account: "17600003315",
source: "production",
status: "online",
projects: [project.id],
quota5h: 0,
quota7d: 0,
lastSeenAt: "2026-04-14T14:00:00+08:00",
note: "",
}),
projects: state.projects.concat(project),
masterAgentTasks: state.masterAgentTasks.concat(
{
taskId: "task-project-detail-1",
projectId: project.id,
taskType: "conversation_reply",
requestMessageId: "project-detail-message-1",
requestText: "项目详情页需要展示任务状态。",
executionPrompt: "请继续回复。",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "master-agent-hermes",
accountId: "hermes-runtime",
accountLabel: "Hermes Runtime",
targetProjectId: project.id,
targetThreadId: "thread-project-detail",
targetThreadDisplayName: "项目详情线程",
status: "completed",
requestedAt: "2026-04-14T14:00:01+08:00",
completedAt: "2026-04-14T14:00:05+08:00",
replyBody: "Hermes 已完成回复。",
requestId: "req-project-detail-1",
sessionId: "session-project-detail-1",
},
{
taskId: "task-project-detail-hidden",
projectId: project.id,
taskType: "conversation_reply",
requestMessageId: "missing-message-id",
requestText: "不应暴露",
executionPrompt: "内部同步",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "master-agent-hermes",
accountId: "hermes-runtime",
accountLabel: "Hermes Runtime",
targetProjectId: project.id,
targetThreadId: "thread-project-detail",
targetThreadDisplayName: "项目详情线程",
status: "completed",
requestedAt: "2026-04-14T14:00:02+08:00",
completedAt: "2026-04-14T14:00:06+08:00",
replyBody: "内部同步回复。",
sessionId: "session-project-detail-hidden",
},
),
threadExecutionWarnings: state.threadExecutionWarnings.concat(
{
warningId: "warning-project-detail-1",
taskId: "task-project-detail-1",
requestMessageId: "project-detail-message-1",
projectId: project.id,
targetProjectId: project.id,
targetThreadId: "thread-project-detail",
sessionId: "session-project-detail-1",
requestId: "req-project-detail-1",
title: "上下文接近上限",
summary: "建议尽快压缩当前线程上下文。",
createdAt: "2026-04-14T14:00:06+08:00",
},
{
warningId: "warning-project-detail-other",
taskId: "task-other",
requestMessageId: "other-message",
projectId: "other-project",
targetProjectId: "other-project",
targetThreadId: "thread-other",
sessionId: "session-other",
requestId: "req-other",
title: "其他线程提醒",
summary: "不应进入当前项目详情。",
createdAt: "2026-04-14T14:00:07+08:00",
},
),
});
const response = await getProjectRoute(
await createAuthedRequest(project.id),
{ params: Promise.resolve({ projectId: project.id }) },
);
assert.equal(response.status, 200);
assert.equal(response.headers.get("Cache-Control"), "private, no-store, max-age=0");
const payload = (await response.json()) as {
ok: boolean;
conversationTasks: Array<{
taskId: string;
requestMessageId: string;
status: string;
requestId?: string;
sessionId?: string;
targetProjectId?: string;
targetThreadId?: string;
}>;
executionWarnings: Array<{
warningId: string;
taskId: string;
requestMessageId: string;
requestId?: string;
sessionId?: string;
targetProjectId?: string;
targetThreadId?: string;
title: string;
summary: string;
createdAt: string;
}>;
};
assert.equal(payload.ok, true);
assert.deepEqual(payload.conversationTasks, [
{
taskId: "task-project-detail-1",
requestMessageId: "project-detail-message-1",
status: "completed",
requestId: "req-project-detail-1",
sessionId: "session-project-detail-1",
targetProjectId: project.id,
targetThreadId: "thread-project-detail",
},
]);
assert.deepEqual(payload.executionWarnings, [
{
warningId: "warning-project-detail-1",
taskId: "task-project-detail-1",
requestMessageId: "project-detail-message-1",
requestId: "req-project-detail-1",
sessionId: "session-project-detail-1",
targetProjectId: project.id,
targetThreadId: "thread-project-detail",
title: "上下文接近上限",
summary: "建议尽快压缩当前线程上下文。",
createdAt: "2026-04-14T14:00:06+08:00",
},
]);
});

View File

@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const testsDir = path.dirname(fileURLToPath(import.meta.url));
const appUiPath = path.join(testsDir, "../src/components/app-ui.tsx");
test("ProjectHeaderActions switches the fourth shortcut to participants for group chats", async () => {
const source = await readFile(appUiPath, "utf8");
assert.match(
source,
/export function ProjectHeaderActions\(\{ projectId, isGroup = false \}: \{ projectId: string; isGroup\?: boolean \}\)/,
"expected header actions to accept an isGroup hint",
);
assert.match(
source,
/href=\{isGroup \? `\/conversations\/\$\{projectId\}\/participants` : `\/conversations\/\$\{projectId\}\/thread-status`\}/,
"expected group chats to route the fourth shortcut to the participants page",
);
assert.match(
source,
/\{isGroup \? "成员状态" : "线程状态"\}/,
"expected the fourth shortcut label to change for group chats",
);
});

View File

@@ -137,6 +137,14 @@ test("GET /api/v1/projects/[projectId]/messages returns a lightweight chat paylo
ok: boolean;
project: { id: string; messages: Array<{ id: string }> };
devices: Array<{ id: string }>;
conversationTasks: Array<{
taskId: string;
requestMessageId: string;
status: string;
sessionId?: string;
requestId?: string;
}>;
executionWarnings: Array<unknown>;
activeThreadContexts?: unknown;
recentAppLogs?: unknown;
openFaults?: unknown;
@@ -152,11 +160,114 @@ test("GET /api/v1/projects/[projectId]/messages returns a lightweight chat paylo
payload.devices.map((device) => device.id),
["device-message-lite"],
);
assert.deepEqual(payload.conversationTasks, []);
assert.deepEqual(payload.executionWarnings, []);
assert.equal("activeThreadContexts" in payload, false);
assert.equal("recentAppLogs" in payload, false);
assert.equal("openFaults" in payload, false);
});
test("GET /api/v1/projects/[projectId]/messages includes current-project conversation task summaries with request/session ids", async () => {
await setup();
const state = await readState();
const project = buildSingleThreadProject("message-lite-tasks");
await writeState({
...state,
devices: state.devices.concat({
id: "device-message-lite",
name: "Mac Studio",
avatar: "M",
account: "17600003315",
source: "production",
status: "online",
projects: [project.id],
quota5h: 0,
quota7d: 0,
lastSeenAt: "2026-04-10T16:20:00+08:00",
note: "",
}),
projects: state.projects.concat(project),
masterAgentTasks: state.masterAgentTasks.concat(
{
taskId: "task-message-lite-1",
projectId: project.id,
taskType: "conversation_reply",
requestMessageId: "message-lite-1",
requestText: "新的消息已经到了。",
executionPrompt: "请继续回复。",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "master-agent-hermes",
accountId: "hermes-runtime",
accountLabel: "Hermes Runtime",
targetProjectId: project.id,
targetThreadId: "thread-message-lite",
targetThreadDisplayName: "轻量消息线程",
status: "completed",
requestedAt: "2026-04-10T16:20:01+08:00",
completedAt: "2026-04-10T16:20:05+08:00",
replyBody: "Hermes 已完成回复。",
requestId: "req-message-lite-1",
sessionId: "session-message-lite-1",
},
{
taskId: "task-message-lite-hidden",
projectId: project.id,
taskType: "conversation_reply",
requestMessageId: "missing-message-id",
requestText: "这条不应暴露",
executionPrompt: "内部同步",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "master-agent-hermes",
accountId: "hermes-runtime",
accountLabel: "Hermes Runtime",
targetProjectId: project.id,
targetThreadId: "thread-message-lite",
targetThreadDisplayName: "轻量消息线程",
status: "completed",
requestedAt: "2026-04-10T16:20:02+08:00",
completedAt: "2026-04-10T16:20:06+08:00",
replyBody: "内部同步回复",
sessionId: "session-hidden",
},
),
});
const response = await getMessagesRoute(
await createAuthedRequest(project.id),
{ params: Promise.resolve({ projectId: project.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
conversationTasks: Array<{
taskId: string;
requestMessageId: string;
status: string;
sessionId?: string;
requestId?: string;
}>;
};
assert.equal(payload.ok, true);
assert.deepEqual(payload.conversationTasks, [
{
taskId: "task-message-lite-1",
requestMessageId: "message-lite-1",
status: "completed",
requestId: "req-message-lite-1",
sessionId: "session-message-lite-1",
targetProjectId: project.id,
targetThreadId: "thread-message-lite",
},
]);
});
test("GET /api/v1/projects/[projectId]/messages disables caching when unauthorized", async () => {
await setup();
@@ -168,3 +279,116 @@ test("GET /api/v1/projects/[projectId]/messages disables caching when unauthoriz
assert.equal(response.status, 401);
assert.equal(response.headers.get("Cache-Control"), "private, no-store, max-age=0");
});
test("GET /api/v1/projects/[projectId]/messages includes execution warnings keyed by request/session/task", async () => {
await setup();
const state = await readState();
const project = buildSingleThreadProject("message-lite-warnings");
await writeState({
...state,
devices: state.devices.concat({
id: "device-message-lite",
name: "Mac Studio",
avatar: "M",
account: "17600003315",
source: "production",
status: "online",
projects: [project.id],
quota5h: 0,
quota7d: 0,
lastSeenAt: "2026-04-10T16:20:00+08:00",
note: "",
}),
projects: state.projects.concat(project),
masterAgentTasks: state.masterAgentTasks.concat({
taskId: "task-message-warning-1",
projectId: project.id,
taskType: "conversation_reply",
requestMessageId: "message-lite-1",
requestText: "新的消息已经到了。",
executionPrompt: "请继续回复。",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "master-agent-hermes",
accountId: "hermes-runtime",
accountLabel: "Hermes Runtime",
targetProjectId: project.id,
targetThreadId: "thread-message-lite",
targetThreadDisplayName: "轻量消息线程",
status: "completed",
requestedAt: "2026-04-10T16:20:01+08:00",
completedAt: "2026-04-10T16:20:05+08:00",
replyBody: "Hermes 已完成回复。",
requestId: "req-message-warning-1",
sessionId: "session-message-warning-1",
}),
threadExecutionWarnings: state.threadExecutionWarnings.concat(
{
warningId: "thread-warning-1",
taskId: "task-message-warning-1",
requestMessageId: "message-lite-1",
projectId: project.id,
targetProjectId: project.id,
targetThreadId: "thread-message-lite",
sessionId: "session-message-warning-1",
requestId: "req-message-warning-1",
title: "上下文即将溢出",
summary: "本次回复已接近上下文上限,建议尽快压缩。",
createdAt: "2026-04-10T16:20:06+08:00",
},
{
warningId: "thread-warning-other",
taskId: "task-other",
requestMessageId: "other-message",
projectId: "other-project",
targetProjectId: "other-project",
targetThreadId: "thread-other",
sessionId: "session-other",
requestId: "req-other",
title: "其他线程 warning",
summary: "不应出现在当前项目。",
createdAt: "2026-04-10T16:20:07+08:00",
},
),
});
const response = await getMessagesRoute(
await createAuthedRequest(project.id),
{ params: Promise.resolve({ projectId: project.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
executionWarnings: Array<{
warningId: string;
taskId: string;
requestMessageId: string;
sessionId?: string;
requestId?: string;
targetProjectId?: string;
targetThreadId?: string;
title: string;
summary: string;
createdAt: string;
}>;
};
assert.equal(payload.ok, true);
assert.deepEqual(payload.executionWarnings, [
{
warningId: "thread-warning-1",
taskId: "task-message-warning-1",
requestMessageId: "message-lite-1",
sessionId: "session-message-warning-1",
requestId: "req-message-warning-1",
targetProjectId: project.id,
targetThreadId: "thread-message-lite",
title: "上下文即将溢出",
summary: "本次回复已接近上下文上限,建议尽快压缩。",
createdAt: "2026-04-10T16:20:06+08:00",
},
]);
});

View File

@@ -33,11 +33,12 @@ test("RealtimeRefresh supports project-scoped refresh filtering", async () => {
});
test("project conversation pages wire project-scoped realtime refresh", async () => {
const [projectPage, goalsPage, versionsPage, threadStatusPage] = await Promise.all([
const [projectPage, goalsPage, versionsPage, threadStatusPage, participantsPage] = await Promise.all([
readWorkspaceFile("src/app/conversations/[projectId]/page.tsx"),
readWorkspaceFile("src/app/conversations/[projectId]/goals/page.tsx"),
readWorkspaceFile("src/app/conversations/[projectId]/versions/page.tsx"),
readWorkspaceFile("src/app/conversations/[projectId]/thread-status/page.tsx"),
readWorkspaceFile("src/app/conversations/[projectId]/participants/page.tsx"),
]);
assert.match(projectPage, /projectId=\{detail\.project\.id\}/, "expected project chat page to pass projectId into RealtimeRefresh");
@@ -46,6 +47,7 @@ test("project conversation pages wire project-scoped realtime refresh", async ()
["goals", goalsPage],
["versions", versionsPage],
["thread-status", threadStatusPage],
["participants", participantsPage],
] as const) {
assert.match(source, /<RealtimeRefresh/, `expected ${label} page to render RealtimeRefresh`);
assert.match(source, /projectId=\{projectId\}/, `expected ${label} page to scope refreshes to the current project`);

View File

@@ -61,3 +61,27 @@ test("RemoteRuntimeAdapter 不会误杀包含路径和 sandbox 描述的有效
assert.equal(normalized.status, "completed");
assert.match(normalized.replyBody ?? "", /gptpluscontrol/);
});
test("RemoteRuntimeAdapter 会透传远端 warning 列表并完成基础清洗", () => {
const normalized = normalizeRemoteExecutionResultForTesting({
status: "completed",
replyBody: "线程执行完成。",
warnings: [
{
title: "上下文接近上限",
summary: "本轮输出较长,建议尽快压缩。",
},
{
title: " ",
summary: " ",
},
],
} as never);
assert.deepEqual(normalized.warnings, [
{
title: "上下文接近上限",
summary: "本轮输出较长,建议尽快压缩。",
},
]);
});

View File

@@ -2,7 +2,7 @@ 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";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { NextRequest } from "next/server";
let runtimeRoot = "";
@@ -11,6 +11,7 @@ let completeMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/task
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
let AUTH_SESSION_COOKIE = "";
async function setup() {
@@ -34,6 +35,7 @@ async function setup() {
createAuthSession = data.createAuthSession;
readState = data.readState;
writeState = data.writeState;
updateProjectAgentControls = data.updateProjectAgentControls;
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
}
@@ -43,6 +45,12 @@ test.after(async () => {
}
});
test.beforeEach(async () => {
await setup();
await rm(runtimeRoot, { recursive: true, force: true });
await mkdir(runtimeRoot, { recursive: true });
});
async function createAuthedRequest(url: string, method: "POST", body: unknown) {
const session = await createAuthSession({
account: "17600003315",
@@ -61,10 +69,27 @@ async function createAuthedRequest(url: string, method: "POST", body: unknown) {
});
}
async function waitFor(predicate: () => Promise<boolean>, timeoutMs = 5_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("waitFor timed out");
}
function findSingleThreadProject(
state: Awaited<ReturnType<typeof readState>>,
projectId?: string,
) {
return state.projects.find((project) => project.id !== "master-agent" && !project.isGroup);
return state.projects.find(
(project) =>
project.id !== "master-agent" &&
!project.isGroup &&
(projectId ? project.id === projectId : true),
);
}
function buildSingleThreadProject(projectId: string) {
@@ -105,19 +130,19 @@ function buildProjectFolderKey(project: ReturnType<typeof buildSingleThreadProje
return `${project.deviceIds[0]}:${folderRef}`;
}
async function ensureSingleThreadProject() {
async function ensureSingleThreadProject(projectId = "single-thread-test") {
const state = await readState();
const existing = findSingleThreadProject(state);
const existing = findSingleThreadProject(state, projectId);
if (existing) {
return existing;
}
const project = buildSingleThreadProject("single-thread-test");
const project = buildSingleThreadProject(projectId);
await writeState({
...state,
projects: state.projects.concat(project),
});
const nextState = await readState();
return findSingleThreadProject(nextState);
return findSingleThreadProject(nextState, projectId);
}
test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task for single-thread projects", async () => {
@@ -137,7 +162,8 @@ test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task fo
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string; taskType: string; status: string } | null;
message: { id: string };
task?: { taskId: string; taskType: string; status: string; requestMessageId: string } | null;
dispatchPlan: null;
};
@@ -146,6 +172,7 @@ test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task fo
assert.ok(payload.task, "expected single-thread message to return a queued task");
assert.equal(payload.task?.taskType, "conversation_reply");
assert.equal(payload.task?.status, "queued");
assert.equal(payload.task?.requestMessageId, payload.message.id);
const nextState = await readState();
const task = nextState.masterAgentTasks.find(
@@ -164,6 +191,333 @@ test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task fo
assert.ok(!task?.executionPrompt?.includes("deviceIds:"), "thread prompt should not include device id labels");
});
test("POST /api/v1/projects/[projectId]/messages preserves default local-agent path when ordinary thread has no backend override", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "继续走默认线程回复链" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
assert.equal(response.status, 200);
const nextState = await readState();
const task = nextState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "继续走默认线程回复链",
);
assert.ok(task, "expected a queued conversation task");
assert.equal(task?.deviceId, singleProject.deviceIds[0]);
assert.equal(task?.accountId, undefined);
assert.equal(task?.accountLabel, undefined);
});
test("POST /api/v1/projects/[projectId]/messages routes ordinary thread conversation_reply to hermes-runtime when backendOverride is set", async () => {
await setup();
const singleProject = await ensureSingleThreadProject("single-thread-hermes-test");
assert.ok(singleProject, "expected a seeded single-thread project");
const hermesDir = await mkdtemp(path.join(os.tmpdir(), "boss-thread-hermes-route-"));
const hermesScriptPath = path.join(hermesDir, "hermes-thread-route-runtime.mjs");
await writeFile(
hermesScriptPath,
`
process.stdout.write("Hermes 路由测试已执行\\n\\n");
process.stdout.write("session_id: hermes-thread-route-123\\n");
`,
"utf8",
);
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_TIMEOUT_MS: process.env.BOSS_HERMES_TIMEOUT_MS,
};
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = hermesScriptPath;
process.env.BOSS_HERMES_TIMEOUT_MS = "1000";
try {
await updateProjectAgentControls(
singleProject.id,
{
backendOverride: "hermes-runtime",
},
"17600003315",
);
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "请让 Hermes 接管当前线程回复" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
assert.equal(response.status, 200);
const nextState = await readState();
const task = nextState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "请让 Hermes 接管当前线程回复",
);
assert.ok(task, "expected a queued conversation task");
assert.equal(task?.deviceId, "master-agent-hermes");
assert.equal(task?.accountId, "hermes-runtime");
assert.equal(task?.accountLabel, "Hermes Runtime");
assert.equal(task?.targetProjectId, singleProject.id);
assert.equal(task?.targetThreadId, singleProject.threadMeta.threadId);
await waitFor(async () => {
const state = await readState();
const currentTask = state.masterAgentTasks.find((item) => item.taskId === task?.taskId);
return currentTask?.status === "completed";
});
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_TIMEOUT_MS = previousEnv.BOSS_HERMES_TIMEOUT_MS;
await rm(hermesDir, { recursive: true, force: true });
}
});
test("POST /api/v1/projects/[projectId]/messages falls back to the default local-agent path when a saved hermes override is no longer available", async () => {
await setup();
const singleProject = await ensureSingleThreadProject("single-thread-hermes-fallback-test");
assert.ok(singleProject, "expected a seeded single-thread project");
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_TIMEOUT_MS: process.env.BOSS_HERMES_TIMEOUT_MS,
};
try {
await updateProjectAgentControls(
singleProject.id,
{
backendOverride: "hermes-runtime",
},
"17600003315",
);
delete process.env.BOSS_HERMES_ENABLED;
delete process.env.BOSS_HERMES_COMMAND;
delete process.env.BOSS_HERMES_ARGS;
delete process.env.BOSS_HERMES_TIMEOUT_MS;
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "Hermes 不可用时请回退到默认线程链路" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
assert.equal(response.status, 200);
const nextState = await readState();
const task = nextState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "Hermes 不可用时请回退到默认线程链路",
);
assert.ok(task, "expected a queued conversation task");
assert.equal(task?.deviceId, singleProject.deviceIds[0]);
assert.equal(task?.accountId, undefined);
assert.equal(task?.accountLabel, undefined);
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_TIMEOUT_MS = previousEnv.BOSS_HERMES_TIMEOUT_MS;
}
});
test("POST /api/v1/projects/[projectId]/messages lets Hermes asynchronously complete ordinary thread replies when backendOverride is set", async () => {
await setup();
const singleProject = await ensureSingleThreadProject("single-thread-hermes-async-test");
assert.ok(singleProject, "expected a seeded single-thread project");
const hermesDir = await mkdtemp(path.join(os.tmpdir(), "boss-thread-hermes-queue-"));
const hermesScriptPath = path.join(hermesDir, "hermes-thread-runtime.mjs");
await writeFile(
hermesScriptPath,
`
const args = process.argv.slice(2);
const queryIndex = args.findIndex((item) => item === "-q" || item === "--query");
const query = queryIndex >= 0 ? args[queryIndex + 1] ?? "" : "";
process.stdout.write("Hermes 线程已接管:" + query + "\\n\\n");
process.stdout.write("session_id: hermes-thread-session-123\\n");
`,
"utf8",
);
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_TIMEOUT_MS: process.env.BOSS_HERMES_TIMEOUT_MS,
};
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = hermesScriptPath;
process.env.BOSS_HERMES_TIMEOUT_MS = "1000";
try {
await updateProjectAgentControls(
singleProject.id,
{
backendOverride: "hermes-runtime",
},
"17600003315",
);
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "请让 Hermes 真正回复当前线程" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
assert.equal(response.status, 200);
const queuedState = await readState();
const task = queuedState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "请让 Hermes 真正回复当前线程",
);
assert.ok(task, "expected a queued Hermes conversation task");
await waitFor(async () => {
const state = await readState();
const currentTask = state.masterAgentTasks.find((item) => item.taskId === task?.taskId);
return currentTask?.status === "completed";
});
const nextState = await readState();
const completedTask = nextState.masterAgentTasks.find((item) => item.taskId === task?.taskId);
assert.equal(completedTask?.status, "completed");
assert.match(completedTask?.replyBody ?? "", /Hermes 线程已接管:/);
assert.equal(completedTask?.sessionId, "hermes-thread-session-123");
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
const mirroredReply = updatedProject?.messages.find((message) =>
message.body.includes("Hermes 线程已接管:"),
);
assert.ok(mirroredReply, "expected Hermes reply to be written back to the thread project");
assert.equal(mirroredReply?.sender, "device");
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_TIMEOUT_MS = previousEnv.BOSS_HERMES_TIMEOUT_MS;
await rm(hermesDir, { recursive: true, force: true });
}
});
test("ordinary thread Hermes async execution blocks leaked environment diagnostics from the chat transcript", async () => {
await setup();
const singleProject = await ensureSingleThreadProject("single-thread-hermes-env-test");
assert.ok(singleProject, "expected a seeded single-thread project");
const hermesDir = await mkdtemp(path.join(os.tmpdir(), "boss-thread-hermes-env-"));
const hermesScriptPath = path.join(hermesDir, "hermes-thread-env-runtime.mjs");
await writeFile(
hermesScriptPath,
`
process.stdout.write("我不能直接把当前会话环境从只读改回可写也不能替你修改这层运行配置。cwd 我可以在命令里指向 /Users/kris/code/gptpluscontrol。\\n\\n");
process.stdout.write("session_id: hermes-thread-env-123\\n");
`,
"utf8",
);
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_TIMEOUT_MS: process.env.BOSS_HERMES_TIMEOUT_MS,
};
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = hermesScriptPath;
process.env.BOSS_HERMES_TIMEOUT_MS = "1000";
try {
await updateProjectAgentControls(
singleProject.id,
{
backendOverride: "hermes-runtime",
},
"17600003315",
);
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "请继续推进当前线程" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
assert.equal(response.status, 200);
const queuedState = await readState();
const task = queuedState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "请继续推进当前线程",
);
assert.ok(task, "expected a queued Hermes conversation task");
await waitFor(async () => {
const state = await readState();
const currentTask = state.masterAgentTasks.find((item) => item.taskId === task?.taskId);
return currentTask?.status === "failed";
});
const nextState = await readState();
const failedTask = nextState.masterAgentTasks.find((item) => item.taskId === task?.taskId);
assert.equal(failedTask?.status, "failed");
assert.match(failedTask?.errorMessage ?? "", /THREAD_ENVIRONMENT_INVALID/);
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
const leakedReply = updatedProject?.messages.find((message) =>
message.body.includes("当前会话环境从只读改回可写"),
);
assert.equal(leakedReply, undefined);
const opsNotice = updatedProject?.messages.find((message) =>
message.body.includes("线程环境异常,请重新绑定到正确项目或工作目录后再试。"),
);
assert.ok(opsNotice, "expected a user-facing system notice instead of raw environment diagnostics");
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_TIMEOUT_MS = previousEnv.BOSS_HERMES_TIMEOUT_MS;
await rm(hermesDir, { recursive: true, force: true });
}
});
test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when the target device prefers gui mode", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
@@ -389,3 +743,73 @@ test("POST /api/v1/master-agent/tasks/[taskId]/complete blocks leaked thread env
);
assert.ok(opsNotice, "expected a user-facing system notice instead of raw environment diagnostics");
});
test("POST /api/v1/master-agent/tasks/[taskId]/complete persists remote warnings onto execution warning records", async () => {
await setup();
const singleProject = await ensureSingleThreadProject("single-thread-warning-test");
assert.ok(singleProject, "expected a seeded single-thread project");
await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "请同步当前线程的风险点" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
const queuedState = await readState();
const task = queuedState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.targetProjectId === singleProject.id &&
item.requestText === "请同步当前线程的风险点",
);
assert.ok(task, "expected a queued conversation_reply task");
const response = await completeMasterTaskRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/master-agent/tasks/${task.taskId}/complete`,
"POST",
{
deviceId: task.deviceId,
status: "completed",
targetProjectId: singleProject.id,
targetThreadId: singleProject.threadMeta.threadId,
requestId: "req-thread-warning-1",
warnings: [
{
title: "上下文接近上限",
summary: "本轮回复过长,建议尽快压缩。",
},
{
title: " ",
summary: " ",
},
],
replyBody: "当前风险点已同步。",
},
),
{ params: Promise.resolve({ taskId: task.taskId }) },
);
assert.equal(response.status, 200);
const nextState = await readState();
const warnings = nextState.threadExecutionWarnings.filter((warning) => warning.taskId === task.taskId);
assert.deepEqual(warnings, [
{
warningId: warnings[0]?.warningId,
taskId: task.taskId,
requestMessageId: task.requestMessageId,
projectId: singleProject.id,
targetProjectId: singleProject.id,
targetThreadId: singleProject.threadMeta.threadId,
sessionId: undefined,
requestId: "req-thread-warning-1",
title: "上下文接近上限",
summary: "本轮回复过长,建议尽快压缩。",
createdAt: warnings[0]?.createdAt,
},
]);
});

View File

@@ -0,0 +1,53 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const testsDir = path.dirname(fileURLToPath(import.meta.url));
const participantsPagePath = path.join(testsDir, "../src/app/conversations/[projectId]/participants/page.tsx");
test("web group participants page renders participant status from project detail payload", async () => {
const source = await readFile(participantsPagePath, "utf8");
assert.match(
source,
/getProjectDetailView\(state, projectId, session\.account\)/,
"expected participants page to derive its state from project detail payload",
);
assert.match(
source,
/detail\.participantsPayload/,
"expected participants page to consume participantsPayload from project detail",
);
assert.match(
source,
/participant\.statusLabel \?\? participant\.status/,
"expected participants page to surface each participant status label",
);
assert.match(
source,
/repairRequired/,
"expected participants page to render repair state when the group is dirty",
);
assert.match(
source,
/GroupParticipantsRepairClient/,
"expected participants page to mount a dedicated repair client when the group needs repair",
);
assert.match(
source,
/availableThreads=\{/,
"expected participants page to pass selectable thread candidates into the repair client",
);
assert.match(
source,
/participant\.canOpenProject \? \(/,
"expected participants page to branch on whether a participant still has an openable project reference",
);
assert.match(
source,
/href=\{`\/conversations\/\$\{participant\.projectId\}`\}/,
"expected participants page to expose a direct conversation link for openable participants",
);
});

View File

@@ -0,0 +1,94 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const testsDir = path.dirname(fileURLToPath(import.meta.url));
const repairClientPath = path.join(testsDir, "../src/components/group-participants-repair-client.tsx");
const participantsPagePath = path.join(testsDir, "../src/app/conversations/[projectId]/participants/page.tsx");
test("web group participants repair client handles empty candidates and disables invalid submissions", async () => {
const source = await readFile(repairClientPath, "utf8");
assert.match(
source,
/const canSubmit = !loading && availableThreads\.length >= 2 && selectedProjectIds\.size >= 2;/,
"expected repair client to derive a submit-ready state from loading, candidates, and selections",
);
assert.match(
source,
/if \(availableThreads\.length < 2\) \{\s*setMessage\("线"\);/s,
"expected repair client to show a specific empty-candidates message before attempting submit",
);
assert.match(
source,
/disabled=\{!canSubmit\}/,
"expected repair submit button to stay disabled until the request is actually valid",
);
assert.match(
source,
/availableThreads\.length \? \(/,
"expected repair client to branch between candidate list and empty-state copy",
);
assert.match(
source,
/try \{/,
"expected repair client to guard network submission with try/finally handling",
);
assert.match(
source,
/catch \(error\) \{/,
"expected repair client to surface a user-facing message when submit fetch rejects",
);
assert.match(
source,
/finally \{\s*setLoading\(false\);/s,
"expected repair client to always clear loading even after a network failure",
);
assert.match(
source,
/router\.replace\(`\/conversations\/\$\{projectId\}\/participants\?repaired=1`\);/,
"expected repair client to persist repair success via URL state before refreshing away",
);
});
test("web group participants page hides repair form when fewer than two real thread candidates remain", async () => {
const source = await readFile(participantsPagePath, "utf8");
assert.match(
source,
/const canRepairGroupMembers = availableThreads\.length >= 2;/,
"expected participants page to compute whether repair is currently possible",
);
assert.match(
source,
/participantsPayload\?\.repairRequired && canRepairGroupMembers \? \(/,
"expected participants page to only mount the repair client when at least two real thread candidates exist",
);
assert.match(
source,
/participantsPayload\?\.repairRequired && !canRepairGroupMembers \? \(/,
"expected participants page to render a fallback guidance card when repair is required but impossible",
);
assert.match(
source,
/当前设备里暂时没有足够的真实线程可用于修复群成员。/,
"expected participants page to explain why direct repair is currently unavailable",
);
assert.match(
source,
/searchParams: Promise<\{ repaired\?: string \| string\[\] \| undefined \}>;/,
"expected participants page to read repaired search params under the current Next page contract",
);
assert.match(
source,
/const repaired = \(await searchParams\)\.repaired;/,
"expected participants page to resolve repaired status from search params",
);
assert.match(
source,
/repaired === \"1\" \? \(/,
"expected participants page to show a durable success acknowledgement after repair refresh",
);
});