Integrate master agent runtime orchestration updates
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
122
tests/android-detail-contract-unification.test.ts
Normal file
122
tests/android-detail-contract-unification.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
52
tests/android-dispatch-reply-wait.test.ts
Normal file
52
tests/android-dispatch-reply-wait.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ test("ExecutionResult 类型守卫能区分 queued 与 immediate", () => {
|
||||
status: "completed",
|
||||
backendId: "openai-api",
|
||||
output: "done",
|
||||
sessionId: "session-completed-1",
|
||||
};
|
||||
const failed: ExecutionImmediateResult = {
|
||||
status: "failed",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
127
tests/hermes-backend-config.test.ts
Normal file
127
tests/hermes-backend-config.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
131
tests/hermes-backend.test.ts
Normal file
131
tests/hermes-backend.test.ts
Normal 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
157
tests/hermes-runner.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 当前未启用。",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
260
tests/project-detail-route.test.ts
Normal file
260
tests/project-detail-route.test.ts
Normal 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
28
tests/project-header-actions.test.ts
Normal file
28
tests/project-header-actions.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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: "本轮输出较长,建议尽快压缩。",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
53
tests/web-group-participants-page.test.ts
Normal file
53
tests/web-group-participants-page.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
94
tests/web-group-participants-repair-client.test.ts
Normal file
94
tests/web-group-participants-repair-client.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user