feat: queue device import review tasks
This commit is contained in:
@@ -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(() -> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user