feat: queue device import review tasks

This commit is contained in:
kris
2026-03-31 22:38:57 +08:00
parent dcbff3cc7d
commit 87ffe19f78
7 changed files with 347 additions and 117 deletions

View File

@@ -23,7 +23,9 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
private String deviceName;
private @Nullable JSONObject currentDraft;
private @Nullable JSONObject currentResolution;
private @Nullable JSONObject currentReviewTask;
private final LinkedHashSet<String> selectedCandidateIds = new LinkedHashSet<>();
private final Runnable reviewPollRunnable = this::reload;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -48,7 +50,11 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution")));
runOnUiThread(() -> applyPayload(
response.json.optJSONObject("draft"),
response.json.optJSONObject("resolution"),
response.json.optJSONObject("reviewTask")
));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -58,9 +64,10 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
});
}
private void applyPayload(@Nullable JSONObject draft, @Nullable JSONObject resolution) {
private void applyPayload(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) {
currentDraft = draft;
currentResolution = resolution;
currentReviewTask = reviewTask;
selectedCandidateIds.clear();
JSONArray selected = draft == null ? null : draft.optJSONArray("selectedCandidateIds");
if (selected != null) {
@@ -74,9 +81,31 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
renderCurrentState();
}
@Override
protected void onDestroy() {
contentLayout.removeCallbacks(reviewPollRunnable);
super.onDestroy();
}
private boolean isReviewPending(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) {
if (draft == null || resolution != null) {
return false;
}
if (!"pending_resolution".equals(draft.optString("status", ""))) {
return false;
}
if (reviewTask == null) {
return false;
}
String taskStatus = reviewTask.optString("status", "");
return "queued".equals(taskStatus) || "running".equals(taskStatus);
}
private void renderCurrentState() {
JSONObject draft = currentDraft;
JSONObject resolution = currentResolution;
JSONObject reviewTask = currentReviewTask;
contentLayout.removeCallbacks(reviewPollRunnable);
replaceContent();
appendContent(BossUi.buildSoftPanel(
this,
@@ -110,7 +139,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
appendContent(BossUi.buildCard(
this,
resolveStatusTitle(draft),
resolveStatusBody(draft, resolution),
resolveStatusBody(draft, resolution, reviewTask),
"候选 " + candidates.length()
+ " · 已选 " + selectedCandidateIds.size()
+ " · 推荐 " + recommendedCount
@@ -183,6 +212,17 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
}
}
if (reviewTask != null) {
appendContent(BossUi.buildCard(
this,
"审核任务",
"状态:" + reviewTask.optString("status", "unknown"),
isReviewPending(draft, resolution, reviewTask)
? "主 Agent 正在生成导入建议,页面会自动刷新。"
: "如果任务失败,可以直接重新生成导入建议。"
));
}
JSONArray appliedProjectNames = draft.optJSONArray("appliedProjectNames");
if (appliedProjectNames != null && appliedProjectNames.length() > 0) {
appendContent(BossUi.buildCard(
@@ -194,7 +234,14 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
}
Button reviewButton = BossUi.buildMiniActionButton(this, "生成导入建议", true);
reviewButton.setEnabled(!selectedCandidateIds.isEmpty());
reviewButton.setEnabled(!selectedCandidateIds.isEmpty() && !isReviewPending(draft, resolution, reviewTask));
if (isReviewPending(draft, resolution, reviewTask)) {
reviewButton.setText("主 Agent 审核中");
} else if (reviewTask != null && "failed".equals(reviewTask.optString("status", ""))) {
reviewButton.setText("重新生成导入建议");
} else if ("resolved".equals(draft.optString("status", "")) || "applied".equals(draft.optString("status", ""))) {
reviewButton.setText("重新生成导入建议");
}
reviewButton.setOnClickListener(v -> reviewSelection());
Button clearButton = BossUi.buildMiniActionButton(this, "清空勾选", false);
clearButton.setEnabled(!selectedCandidateIds.isEmpty());
@@ -208,6 +255,9 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
applyButton.setOnClickListener(v -> applyResolution());
appendContent(BossUi.buildInlineActionRow(this, reviewButton, clearButton, applyButton));
setRefreshing(false);
if (isReviewPending(draft, resolution, reviewTask)) {
contentLayout.postDelayed(reviewPollRunnable, 2000);
}
}
private String resolveStatusTitle(@Nullable JSONObject draft) {
@@ -233,7 +283,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
return "导入草稿";
}
private String resolveStatusBody(@Nullable JSONObject draft, @Nullable JSONObject resolution) {
private String resolveStatusBody(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) {
if (draft == null) {
return "先让设备完成首次 heartbeat 并上报候选线程,导入草稿就会出现在这里。";
}
@@ -245,6 +295,12 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
return "先勾选想导入的线程,再生成导入建议。";
}
if ("pending_resolution".equals(status)) {
if (isReviewPending(draft, resolution, reviewTask)) {
return "勾选已保存,主 Agent 正在整理导入建议,页面会自动刷新。";
}
if (reviewTask != null && "failed".equals(reviewTask.optString("status", ""))) {
return "主 Agent 这次没能生成导入建议。可以稍后重新生成,当前勾选会保留。";
}
return "勾选已保存,接下来会生成导入建议。";
}
if ("resolved".equals(status)) {
@@ -306,14 +362,21 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
throw new IllegalStateException(reviewResponse.message());
}
runOnUiThread(() -> {
showMessage("已生成导入建议");
applyPayload(reviewResponse.json.optJSONObject("draft"), reviewResponse.json.optJSONObject("resolution"));
boolean hasResolution = reviewResponse.json.optJSONObject("resolution") != null;
showMessage(hasResolution ? "已生成导入建议" : "已提交给主 Agent 审核");
applyPayload(
reviewResponse.json.optJSONObject("draft"),
reviewResponse.json.optJSONObject("resolution"),
reviewResponse.json.optJSONObject("reviewTask") != null
? reviewResponse.json.optJSONObject("reviewTask")
: reviewResponse.json.optJSONObject("task")
);
});
} catch (Exception error) {
final JSONObject fallbackDraft = selectedDraft;
runOnUiThread(() -> {
if (fallbackDraft != null) {
applyPayload(fallbackDraft, null);
applyPayload(fallbackDraft, null, null);
} else {
setRefreshing(false);
}
@@ -337,7 +400,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
}
runOnUiThread(() -> {
showMessage("已清空当前勾选");
applyPayload(response.json.optJSONObject("draft"), null);
applyPayload(response.json.optJSONObject("draft"), null, null);
});
} catch (Exception error) {
runOnUiThread(() -> {
@@ -362,7 +425,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
}
runOnUiThread(() -> {
showMessage("已应用导入");
applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution"));
applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution"), null);
});
} catch (Exception error) {
runOnUiThread(() -> {

View File

@@ -36,6 +36,7 @@ public class DeviceImportDraftActivityTest {
activity,
"applyPayload",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingDraft()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
@@ -62,7 +63,8 @@ public class DeviceImportDraftActivityTest {
activity,
"applyPayload",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedDraft()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedResolution())
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedResolution()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View content = activity.findViewById(R.id.screen_content);
@@ -73,6 +75,32 @@ public class DeviceImportDraftActivityTest {
assertTrue(viewTreeContainsText(content, "已导入"));
}
@Test
public void renderCurrentStateShowsQueuedReviewTaskCopy() throws Exception {
TestDeviceImportDraftActivity activity = Robolectric
.buildActivity(
TestDeviceImportDraftActivity.class,
new Intent()
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"applyPayload",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingResolutionDraft()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildQueuedReviewTask())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "主 Agent 审核中"));
assertTrue(viewTreeContainsText(content, "审核任务"));
assertTrue(viewTreeContainsText(content, "状态queued"));
}
private static JSONObject buildPendingDraft() throws Exception {
return new JSONObject()
.put("draftId", "draft-1")
@@ -125,6 +153,24 @@ public class DeviceImportDraftActivityTest {
.put("suggestedImport", true)));
}
private static JSONObject buildPendingResolutionDraft() throws Exception {
return new JSONObject()
.put("draftId", "draft-1")
.put("deviceId", "device-1")
.put("status", "pending_resolution")
.put("selectedCandidateIds", new JSONArray().put("candidate-1"))
.put("appliedProjectNames", new JSONArray())
.put("candidates", new JSONArray()
.put(new JSONObject()
.put("candidateId", "candidate-1")
.put("deviceId", "device-1")
.put("folderName", "北区试产线")
.put("threadId", "thread-1")
.put("threadDisplayName", "北区试产线回归")
.put("lastActiveAt", "2026-03-30T10:18:00+08:00")
.put("suggestedImport", true)));
}
private static JSONObject buildAppliedResolution() throws Exception {
return new JSONObject()
.put("resolutionId", "resolution-1")
@@ -147,6 +193,13 @@ public class DeviceImportDraftActivityTest {
.put("reason", "作为独立聊天窗口导入。")));
}
private static JSONObject buildQueuedReviewTask() throws Exception {
return new JSONObject()
.put("taskId", "mastertask-1")
.put("taskType", "device_import_resolution")
.put("status", "queued");
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();

View File

@@ -2,12 +2,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { DeviceImportDraft, DeviceImportResolution } from "@/lib/boss-data";
import type { DeviceImportDraft, DeviceImportResolution, MasterAgentTask } from "@/lib/boss-data";
type ImportDraftResponse = {
ok: boolean;
draft?: DeviceImportDraft | null;
resolution?: DeviceImportResolution | null;
reviewTask?: MasterAgentTask | null;
message?: string;
};
@@ -30,6 +31,17 @@ export type DeviceImportDraftViewCopy = {
appliedProjectNames: string[];
};
function isDeviceImportReviewPending(
draft: DeviceImportDraft | null,
resolution: DeviceImportResolution | null,
reviewTask: MasterAgentTask | null,
) {
if (!draft || draft.status !== "pending_resolution" || resolution) {
return false;
}
return reviewTask?.status === "queued" || reviewTask?.status === "running";
}
function groupCandidates(draft: DeviceImportDraft | null) {
const groups = new Map<string, Array<DeviceImportDraft["candidates"][number]>>();
for (const candidate of draft?.candidates ?? []) {
@@ -52,6 +64,7 @@ function joinProjectNames(projectNames: string[]) {
export function describeDeviceImportDraft(
draft: DeviceImportDraft | null,
resolution: DeviceImportResolution | null,
reviewTask: MasterAgentTask | null = null,
): DeviceImportDraftViewCopy {
const candidateCount = draft?.candidates.length ?? 0;
const selectedCount = draft?.selectedCandidateIds.length ?? 0;
@@ -90,10 +103,21 @@ export function describeDeviceImportDraft(
statusBody = "先勾选想导入的线程,再生成导入建议。";
break;
case "pending_resolution":
statusTitle = "建议生成中";
statusBody = "勾选已保存,接下来会生成导入建议。";
if (isDeviceImportReviewPending(draft, resolution, reviewTask)) {
statusTitle = "主 Agent 审核中";
statusBody = "勾选已保存,主 Agent 正在整理导入建议,页面会自动刷新。";
} else if (reviewTask?.status === "failed") {
statusTitle = "建议生成失败";
statusBody = "主 Agent 这次没能生成导入建议。可以稍后重新生成,当前勾选会保留。";
} else {
statusTitle = "建议生成中";
statusBody = "勾选已保存,接下来会生成导入建议。";
}
resultTitle = "导入建议";
resultBody = "导入建议生成后,会先显示每个线程的处理方式和原因。";
resultBody =
reviewTask?.status === "failed"
? "导入建议生成失败后,可以直接重新生成,不需要重新勾选。"
: "导入建议生成后,会先显示每个线程的处理方式和原因。";
break;
case "resolved":
statusTitle = "建议已生成";
@@ -149,6 +173,7 @@ export function DeviceImportDraftManager({
const [feedback, setFeedback] = useState<Feedback | null>(null);
const [draft, setDraft] = useState<DeviceImportDraft | null>(null);
const [resolution, setResolution] = useState<DeviceImportResolution | null>(null);
const [reviewTask, setReviewTask] = useState<MasterAgentTask | null>(null);
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const loadDraft = useCallback(async () => {
@@ -158,6 +183,7 @@ export function DeviceImportDraftManager({
const data = (await response.json()) as ImportDraftResponse;
setDraft(data.draft ?? null);
setResolution(data.resolution ?? null);
setReviewTask(data.reviewTask ?? null);
setSelectedCandidateIds(data.draft?.selectedCandidateIds ?? []);
setFeedback(
data.ok
@@ -181,8 +207,21 @@ export function DeviceImportDraftManager({
return () => window.clearTimeout(timer);
}, [loadDraft]);
useEffect(() => {
if (!isDeviceImportReviewPending(draft, resolution, reviewTask)) {
return;
}
const timer = window.setTimeout(() => {
void loadDraft();
}, 2000);
return () => window.clearTimeout(timer);
}, [draft, resolution, reviewTask, loadDraft]);
const groups = useMemo(() => groupCandidates(draft), [draft]);
const copy = useMemo(() => describeDeviceImportDraft(draft, resolution), [draft, resolution]);
const copy = useMemo(
() => describeDeviceImportDraft(draft, resolution, reviewTask),
[draft, resolution, reviewTask],
);
function toggle(candidateId: string) {
setSelectedCandidateIds((current) =>
@@ -211,6 +250,7 @@ export function DeviceImportDraftManager({
}
setDraft(result.draft ?? draft);
setResolution(null);
setReviewTask(null);
setSelectedCandidateIds([]);
setFeedback({ tone: "success", text: "已清空当前勾选,你可以重新选择要导入的线程。" });
} catch (error) {
@@ -242,6 +282,7 @@ export function DeviceImportDraftManager({
}
setDraft(selectResult.draft ?? draft);
setResolution(null);
setReviewTask(null);
setSelectedCandidateIds(selectResult.draft?.selectedCandidateIds ?? selectedCandidateIds);
const reviewResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/review`, {
@@ -254,6 +295,8 @@ export function DeviceImportDraftManager({
message?: string;
draft?: DeviceImportDraft;
resolution?: DeviceImportResolution;
reviewTask?: MasterAgentTask;
task?: MasterAgentTask;
};
if (!reviewResult.ok) {
setFeedback({ tone: "error", text: reviewResult.message ?? "导入建议生成失败,已保留当前勾选。" });
@@ -261,12 +304,18 @@ export function DeviceImportDraftManager({
}
setDraft(reviewResult.draft ?? selectResult.draft ?? null);
setResolution(reviewResult.resolution ?? null);
setReviewTask(reviewResult.reviewTask ?? reviewResult.task ?? null);
setSelectedCandidateIds(
reviewResult.draft?.selectedCandidateIds ??
selectResult.draft?.selectedCandidateIds ??
selectedCandidateIds,
);
setFeedback({ tone: "success", text: "已生成导入建议,先看推荐理由再应用导入。" });
setFeedback({
tone: reviewResult.resolution ? "success" : "info",
text: reviewResult.resolution
? "已生成导入建议,先看推荐理由再应用导入。"
: "已提交给主 Agent 审核,建议生成后会自动刷新。",
});
} catch (error) {
setFeedback({
tone: "error",
@@ -297,6 +346,7 @@ export function DeviceImportDraftManager({
}
setDraft(result.draft ?? draft);
setResolution(result.resolution ?? resolution);
setReviewTask(null);
setSelectedCandidateIds(result.draft?.selectedCandidateIds ?? draft?.selectedCandidateIds ?? []);
setFeedback({
tone: "success",
@@ -353,6 +403,12 @@ export function DeviceImportDraftManager({
{draft.status}
<br />
{selectedCandidateIds.length}
{reviewTask ? (
<>
<br />
{reviewTask.status}
</>
) : null}
{draft.status === "pending_candidates" ? (
<>
<br />
@@ -451,10 +507,14 @@ export function DeviceImportDraftManager({
<button
type="button"
onClick={() => void reviewSelection()}
disabled={loading || selectedCandidateIds.length === 0}
disabled={loading || selectedCandidateIds.length === 0 || isDeviceImportReviewPending(draft, resolution, reviewTask)}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
>
{draft?.status === "resolved" || draft?.status === "applied" ? "重新生成导入建议" : "生成导入建议"}
{isDeviceImportReviewPending(draft, resolution, reviewTask)
? "主 Agent 审核中"
: draft?.status === "resolved" || draft?.status === "applied" || reviewTask?.status === "failed"
? "重新生成导入建议"
: "生成导入建议"}
</button>
<button
type="button"

View File

@@ -5035,6 +5035,7 @@ export async function completeMasterAgentTask(payload: {
draftId: draft.draftId,
});
}
publishBossEvent("devices.updated", { deviceId: draft.deviceId });
} else if (task.taskType === "dispatch_execution") {
if (!task.dispatchExecutionId || !task.targetProjectId || !task.targetThreadId) {
throw new Error("MASTER_AGENT_DISPATCH_EXECUTION_CONTEXT_REQUIRED");
@@ -5929,7 +5930,14 @@ export async function getLatestDeviceImportDraft(deviceId: string) {
const resolution = draft?.resolutionId
? state.deviceImportResolutions.find((item) => item.resolutionId === draft.resolutionId) ?? null
: state.deviceImportResolutions.find((item) => item.deviceId === deviceId) ?? null;
return { draft, resolution };
const reviewTask = draft
? state.masterAgentTasks.find(
(item) =>
item.taskType === "device_import_resolution" &&
item.deviceImportDraftId === draft.draftId,
) ?? null
: null;
return { draft, resolution, reviewTask };
}
export async function previewDeviceImportResolution(input: { deviceId: string }) {

View File

@@ -12,7 +12,6 @@ import {
getRuntimeAiAccountById,
getMasterAgentRuntimeAccount,
getMasterAgentTask,
previewDeviceImportResolution,
queueMasterAgentTask,
readState,
isDispatchableThreadProject,
@@ -798,87 +797,6 @@ function buildDeviceImportResolutionPrompt(params: {
].join("\n");
}
type DeviceImportResolutionTaskResult =
| {
ok: true;
taskId: string;
status: "completed";
draft: NonNullable<Awaited<ReturnType<typeof getLatestDeviceImportDraft>>["draft"]>;
resolution: NonNullable<Awaited<ReturnType<typeof getLatestDeviceImportDraft>>["resolution"]>;
}
| {
ok: false;
taskId: string;
status: "failed";
draft: Awaited<ReturnType<typeof getLatestDeviceImportDraft>>["draft"];
resolution: Awaited<ReturnType<typeof getLatestDeviceImportDraft>>["resolution"];
error: string;
};
async function resolveDeviceImportResolutionTask(taskId: string): Promise<DeviceImportResolutionTaskResult> {
const task = await getMasterAgentTask(taskId);
if (!task) {
throw new Error("MASTER_AGENT_TASK_NOT_FOUND");
}
if (task.taskType !== "device_import_resolution" || !task.deviceImportDraftId) {
throw new Error("MASTER_AGENT_TASK_TYPE_INVALID");
}
const draftRecord = await readState();
const draft = draftRecord.deviceImportDrafts.find((item) => item.draftId === task.deviceImportDraftId);
if (!draft) {
throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND");
}
try {
const proposal = await previewDeviceImportResolution({ deviceId: draft.deviceId });
await completeMasterAgentTask({
taskId: task.taskId,
deviceId: task.deviceId,
status: "completed",
replyBody: JSON.stringify(
{
summary: proposal.summary,
items: proposal.items.map((item) => ({
candidateId: item.candidateId,
action: item.action,
targetProjectId: item.targetProjectId,
reason: item.reason,
})),
},
null,
2,
),
});
const latest = await getLatestDeviceImportDraft(draft.deviceId);
return {
ok: true as const,
taskId: task.taskId,
status: "completed" as const,
draft: latest.draft!,
resolution: latest.resolution!,
};
} catch (error) {
const message = error instanceof Error ? error.message : "DEVICE_IMPORT_RESOLUTION_FAILED";
await completeMasterAgentTask({
taskId: task.taskId,
deviceId: task.deviceId,
status: "failed",
errorMessage: message,
});
const latest = await getLatestDeviceImportDraft(draft.deviceId);
return {
ok: false as const,
taskId: task.taskId,
status: "failed" as const,
draft: latest.draft,
resolution: latest.resolution,
error: message,
};
}
}
export async function queueDeviceImportResolutionTask(params: {
deviceId: string;
reviewedBy: string;
@@ -927,7 +845,20 @@ export async function queueDeviceImportResolutionTask(params: {
deviceImportDraftId: draft.draftId,
});
return resolveDeviceImportResolutionTask(task.taskId);
const latest = await getLatestDeviceImportDraft(draft.deviceId);
return {
ok: true as const,
taskId: task.taskId,
task: {
taskId: task.taskId,
taskType: task.taskType,
status: task.status,
deviceId: task.deviceId,
deviceImportDraftId: task.deviceImportDraftId,
},
draft: latest.draft ?? undefined,
...(latest.resolution ? { resolution: latest.resolution } : {}),
};
}
async function waitForMasterAgentTaskCompletion(taskId: string, timeoutMs = 55_000) {

View File

@@ -51,6 +51,52 @@ test("device import draft copy explains selection and recommendation state", ()
assert.equal(view.recommendedCount, 1);
});
test("device import draft copy shows pending agent review when task is queued", () => {
const view = describeDeviceImportDraft(
{
draftId: "draft-1",
deviceId: "device-1",
status: "pending_resolution",
candidates: [
{
candidateId: "candidate-1",
deviceId: "device-1",
folderName: "北区试产线",
folderRef: "north-line",
threadId: "thread-1",
threadDisplayName: "北区试产线回归",
codexFolderRef: "north-line",
codexThreadRef: "thread-1",
lastActiveAt: "2026-03-30T10:18:00+08:00",
suggestedImport: true,
},
],
selectedCandidateIds: ["candidate-1"],
appliedProjectNames: [],
createdAt: "2026-03-30T10:00:00+08:00",
updatedAt: "2026-03-30T10:20:00+08:00",
},
null,
{
taskId: "mastertask-1",
projectId: "master-agent",
taskType: "device_import_resolution",
requestMessageId: "draft-1",
requestText: "请审核设备导入",
executionPrompt: "prompt",
requestedBy: "17600003315",
requestedByAccount: "17600003315",
deviceId: "mac-studio",
deviceImportDraftId: "draft-1",
status: "queued",
requestedAt: "2026-03-30T10:20:10+08:00",
},
);
assert.equal(view.statusTitle, "主 Agent 审核中");
assert.match(view.statusBody, /页面会自动刷新/);
});
test("device import draft copy shows applied project names after import", () => {
const view = describeDeviceImportDraft(
{

View File

@@ -11,6 +11,7 @@ let deviceHeartbeatRoute: (typeof import("../src/app/api/device-heartbeat/route"
let getImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/route"))["GET"];
let selectImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/select/route"))["POST"];
let reviewImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/review/route"))["POST"];
let completeMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route"))["POST"];
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"];
@@ -23,13 +24,14 @@ async function setup() {
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [enrollmentModule, heartbeatModule, importDraftModule, selectModule, reviewModule, applyModule, data, auth] =
const [enrollmentModule, heartbeatModule, importDraftModule, selectModule, reviewModule, completeModule, applyModule, data, auth] =
await Promise.all([
import("../src/app/api/v1/devices/enrollments/route.ts"),
import("../src/app/api/device-heartbeat/route.ts"),
import("../src/app/api/v1/devices/[deviceId]/import-draft/route.ts"),
import("../src/app/api/v1/devices/[deviceId]/import-draft/select/route.ts"),
import("../src/app/api/v1/devices/[deviceId]/import-draft/review/route.ts"),
import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts"),
import("../src/app/api/v1/devices/[deviceId]/import-draft/apply/route.ts"),
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
@@ -40,6 +42,7 @@ async function setup() {
getImportDraftRoute = importDraftModule.GET;
selectImportDraftRoute = selectModule.POST;
reviewImportDraftRoute = reviewModule.POST;
completeMasterTaskRoute = completeModule.POST;
applyImportDraftRoute = applyModule.POST;
createAuthSession = data.createAuthSession;
readState = data.readState;
@@ -80,7 +83,7 @@ async function createAuthedRequestFor(
});
}
test("device import draft flow scans candidates, selects imports, resolves suggestions, and creates real chat windows", async () => {
test("device import draft review queues a master-agent task, then completion writes back a ready resolution and apply still works", async () => {
await setup();
const enrollmentResponse = await createEnrollmentRoute(
@@ -179,22 +182,60 @@ test("device import draft flow scans candidates, selects imports, resolves sugge
);
assert.equal(reviewResponse.status, 200);
const reviewPayload = (await reviewResponse.json()) as {
resolution: { summary: string; items: Array<{ action: string; threadDisplayName: string }> };
draft?: { status: string; selectedCandidateIds: string[] };
resolution?: { summary: string; items: Array<{ action: string; threadDisplayName: string }> };
task: { taskId: string; status: "queued" | "running" | "completed" | "failed" };
};
assert.match(reviewPayload.resolution.summary, /MacBook Pro 导入建议/);
assert.deepEqual(
reviewPayload.resolution.items.map((item) => item.action),
["create_thread_conversation"],
);
assert.equal(reviewPayload.task.status, "queued");
assert.equal(reviewPayload.draft?.status, "pending_resolution");
assert.equal(reviewPayload.resolution, undefined);
const reviewedState = await readState();
const resolutionTask = reviewedState.masterAgentTasks.find(
(task) =>
task.taskType === "device_import_resolution" &&
task.deviceImportDraftId &&
task.status === "completed",
task.status === "queued",
);
assert.ok(resolutionTask, "expected import review to leave a master-agent task trace");
assert.ok(resolutionTask, "expected import review to leave a queued master-agent task trace");
const completionResponse = await completeMasterTaskRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/master-agent/tasks/${reviewPayload.task.taskId}/complete`,
"POST",
{
deviceId: reviewPayload.task.deviceId,
status: "completed",
replyBody: JSON.stringify(
{
summary: "MacBook Pro 导入建议:将回归线程导入为独立会话。",
items: [
{
candidateId: draftPayload.draft?.candidates[0]?.candidateId,
action: "create_thread_conversation",
reason: "需要保留独立上下文,建议新建会话。",
},
],
},
null,
2,
),
},
),
{ params: Promise.resolve({ taskId: reviewPayload.task.taskId }) },
);
assert.equal(completionResponse.status, 200);
const completedState = await readState();
const completedDraft = completedState.deviceImportDrafts.find(
(draft) => draft.deviceId === enrollmentPayload.device.id,
);
const completedResolution = completedState.deviceImportResolutions.find(
(resolution) => resolution.deviceId === enrollmentPayload.device.id,
);
assert.equal(completedDraft?.status, "resolved");
assert.equal(completedResolution?.status, "ready");
assert.match(completedResolution?.summary ?? "", /MacBook Pro 导入建议/);
const applyResponse = await applyImportDraftRoute(
await createAuthedRequest(
@@ -375,15 +416,43 @@ test("device import apply is idempotent and heartbeat preserves applied status",
).status,
200,
);
const reviewResponse = await reviewImportDraftRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/review`,
"POST",
{},
),
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
);
assert.equal(reviewResponse.status, 200);
const reviewPayload = (await reviewResponse.json()) as {
task: { taskId: string; deviceId: string; status: "queued" };
};
assert.equal(
(
await reviewImportDraftRoute(
await completeMasterTaskRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/review`,
`http://127.0.0.1:3000/api/v1/master-agent/tasks/${reviewPayload.task.taskId}/complete`,
"POST",
{},
{
deviceId: reviewPayload.task.deviceId,
status: "completed",
replyBody: JSON.stringify(
{
summary: "Studio Mac 导入建议:将导入目录里的线程导入为独立会话。",
items: selectedCandidateIds.map((candidateId) => ({
candidateId,
action: "create_thread_conversation",
reason: "需要保留独立上下文,建议新建会话。",
})),
},
null,
2,
),
},
),
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
{ params: Promise.resolve({ taskId: reviewPayload.task.taskId }) },
)
).status,
200,