feat: add dispatch retry and import recovery flows
This commit is contained in:
@@ -137,6 +137,16 @@ public class BossApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse retryDispatchPlan(String projectId, String planId) throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(
|
||||
"POST",
|
||||
"/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/retry",
|
||||
new JSONObject().toString(),
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
CHAT_FLOW_READ_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("name", name);
|
||||
|
||||
@@ -95,7 +95,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
|
||||
JSONArray candidates = draft.optJSONArray("candidates");
|
||||
if (candidates == null || candidates.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前还没有可导入的线程。"));
|
||||
appendContent(BossUi.buildEmptyCard(this, "设备已在线,但当前还没有发现可导入线程。可以稍后刷新重试。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
@@ -196,14 +196,17 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
Button reviewButton = BossUi.buildMiniActionButton(this, "生成导入建议", true);
|
||||
reviewButton.setEnabled(!selectedCandidateIds.isEmpty());
|
||||
reviewButton.setOnClickListener(v -> reviewSelection());
|
||||
Button clearButton = BossUi.buildMiniActionButton(this, "清空勾选", false);
|
||||
clearButton.setEnabled(!selectedCandidateIds.isEmpty());
|
||||
clearButton.setOnClickListener(v -> clearSelection());
|
||||
Button applyButton = BossUi.buildMiniActionButton(
|
||||
this,
|
||||
"applied".equals(draft.optString("status", "")) ? "已导入" : "应用导入",
|
||||
false
|
||||
);
|
||||
applyButton.setEnabled(resolution != null && !"applied".equals(draft.optString("status", "")));
|
||||
applyButton.setEnabled(resolution != null && "resolved".equals(draft.optString("status", "")));
|
||||
applyButton.setOnClickListener(v -> applyResolution());
|
||||
appendContent(BossUi.buildInlineActionRow(this, reviewButton, applyButton));
|
||||
appendContent(BossUi.buildInlineActionRow(this, reviewButton, clearButton, applyButton));
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
@@ -287,6 +290,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
JSONObject selectedDraft = null;
|
||||
try {
|
||||
JSONArray selected = new JSONArray();
|
||||
for (String candidateId : selectedCandidateIds) {
|
||||
@@ -296,6 +300,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
if (!selectResponse.ok()) {
|
||||
throw new IllegalStateException(selectResponse.message());
|
||||
}
|
||||
selectedDraft = selectResponse.json.optJSONObject("draft");
|
||||
BossApiClient.ApiResponse reviewResponse = apiClient.reviewDeviceImportDraft(deviceId);
|
||||
if (!reviewResponse.ok()) {
|
||||
throw new IllegalStateException(reviewResponse.message());
|
||||
@@ -304,10 +309,40 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
showMessage("已生成导入建议");
|
||||
applyPayload(reviewResponse.json.optJSONObject("draft"), reviewResponse.json.optJSONObject("resolution"));
|
||||
});
|
||||
} catch (Exception error) {
|
||||
final JSONObject fallbackDraft = selectedDraft;
|
||||
runOnUiThread(() -> {
|
||||
if (fallbackDraft != null) {
|
||||
applyPayload(fallbackDraft, null);
|
||||
} else {
|
||||
setRefreshing(false);
|
||||
}
|
||||
showMessage("导入建议生成失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void clearSelection() {
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
showMessage("缺少 deviceId");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.selectDeviceImportCandidates(deviceId, new JSONArray());
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("已清空当前勾选");
|
||||
applyPayload(response.json.optJSONObject("draft"), null);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("导入建议生成失败:" + error.getMessage());
|
||||
showMessage("清空勾选失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -245,6 +245,23 @@ public final class ProjectChatUiState {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static JSONObject latestRejectedDispatchPlan(@Nullable JSONArray plans) {
|
||||
if (plans == null || plans.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < plans.length(); i++) {
|
||||
JSONObject plan = plans.optJSONObject(i);
|
||||
if (plan == null) {
|
||||
continue;
|
||||
}
|
||||
if ("rejected".equals(plan.optString("status", ""))) {
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<String> dispatchPlanApprovedTargetIds(@Nullable JSONObject plan) {
|
||||
ArrayList<String> approved = new ArrayList<>();
|
||||
if (plan == null) {
|
||||
|
||||
@@ -70,6 +70,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private String projectCollaborationMode = "development";
|
||||
private String projectApprovalState = "not_required";
|
||||
private @Nullable JSONObject currentPendingDispatchPlan;
|
||||
private @Nullable JSONObject currentRejectedDispatchPlan;
|
||||
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
|
||||
private ActivityResultLauncher<Intent> conversationInfoLauncher;
|
||||
private ActivityResultLauncher<Intent> forwardTargetLauncher;
|
||||
@@ -294,6 +295,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
currentAgentModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("modelOverride", null));
|
||||
currentReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("reasoningEffortOverride", null));
|
||||
currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans);
|
||||
currentRejectedDispatchPlan = currentPendingDispatchPlan == null
|
||||
? ProjectChatUiState.latestRejectedDispatchPlan(dispatchPlans)
|
||||
: null;
|
||||
conversationInfoReady = project != null;
|
||||
updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices));
|
||||
|
||||
@@ -302,6 +306,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
pendingOutgoingBubble = null;
|
||||
if (currentPendingDispatchPlan != null) {
|
||||
appendContent(buildPendingDispatchPlanView(currentPendingDispatchPlan));
|
||||
} else if (projectIsGroup && "rejected".equals(projectApprovalState) && currentRejectedDispatchPlan != null) {
|
||||
appendContent(buildRejectedDispatchPlanView(currentRejectedDispatchPlan));
|
||||
}
|
||||
if (projectIsGroup && participantsPayload != null && participantsPayload.optBoolean("repairRequired", false)) {
|
||||
appendContent(buildRepairGroupMembersView(participantsPayload));
|
||||
@@ -505,6 +511,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
projectApprovalState = collaborationGate.optString("approvalState", projectApprovalState);
|
||||
}
|
||||
currentPendingDispatchPlan = dispatchPlan;
|
||||
currentRejectedDispatchPlan = null;
|
||||
if (dispatchPlan != null) {
|
||||
composerSending = false;
|
||||
updateComposerSendButtonState();
|
||||
@@ -790,6 +797,21 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
return container;
|
||||
}
|
||||
|
||||
private View buildRejectedDispatchPlanView(JSONObject dispatchPlan) {
|
||||
LinearLayout container = new LinearLayout(this);
|
||||
container.setOrientation(LinearLayout.VERTICAL);
|
||||
container.addView(BossUi.buildCard(
|
||||
this,
|
||||
"上次推荐已拒绝",
|
||||
ProjectChatUiState.summarizeDispatchPlan(dispatchPlan),
|
||||
"如果还想继续当前协作,可以重新生成推荐。"
|
||||
));
|
||||
Button retryButton = BossUi.buildMiniActionButton(this, "重新生成推荐", true);
|
||||
retryButton.setOnClickListener(v -> retryDispatchPlan(dispatchPlan));
|
||||
container.addView(BossUi.buildInlineActionRow(this, retryButton));
|
||||
return container;
|
||||
}
|
||||
|
||||
private View buildRepairGroupMembersView(JSONObject participantsPayload) {
|
||||
String repairReason = participantsPayload.optString("repairReason", "当前群聊里有失效线程,请先修复群成员。");
|
||||
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
|
||||
@@ -902,6 +924,39 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void retryDispatchPlan(JSONObject dispatchPlan) {
|
||||
String planId = dispatchPlan.optString("planId", "").trim();
|
||||
if (planId.isEmpty()) {
|
||||
showMessage("缺少调度方案 ID");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.retryDispatchPlan(projectId, planId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
JSONObject nextPlan = response.json.optJSONObject("dispatchPlan");
|
||||
runOnUiThread(() -> {
|
||||
currentRejectedDispatchPlan = null;
|
||||
currentPendingDispatchPlan = nextPlan;
|
||||
applyDispatchPlanActionResponse(response.json);
|
||||
showMessage("主 Agent 已重新生成推荐");
|
||||
reload(true);
|
||||
if (nextPlan != null) {
|
||||
showDispatchPlanConfirmation(nextPlan);
|
||||
}
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("重新生成推荐失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private View buildMessageView(JSONObject message) {
|
||||
String messageId = message.optString("id", "");
|
||||
String sender = message.optString("sender", "");
|
||||
@@ -1946,8 +2001,14 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
JSONObject plan = response.optJSONObject("plan");
|
||||
if (plan != null) {
|
||||
String status = plan.optString("status", "");
|
||||
if (!"pending_user_confirmation".equals(status)) {
|
||||
if ("pending_user_confirmation".equals(status)) {
|
||||
currentPendingDispatchPlan = plan;
|
||||
currentRejectedDispatchPlan = null;
|
||||
} else {
|
||||
currentPendingDispatchPlan = null;
|
||||
if ("rejected".equals(status)) {
|
||||
currentRejectedDispatchPlan = plan;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,21 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals("{}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retryDispatchPlanUsesProjectScopedRetryEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/retry"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.retryDispatchPlan("p1", "plan-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/retry", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(65000, connection.readTimeoutValue);
|
||||
assertEquals("{}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getProjectAgentControlsUsesScopedEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));
|
||||
|
||||
@@ -392,6 +392,47 @@ public class ProjectDetailActivityUiTest {
|
||||
assertEquals(null, ReflectionHelpers.getField(activity, "currentPendingDispatchPlan"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyDispatchPlanActionResponseStoresRejectedPlanForRecovery() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "group-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "巡检协作群");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.setField(activity, "projectCollaborationMode", "approval_required");
|
||||
ReflectionHelpers.setField(activity, "projectApprovalState", "pending_user");
|
||||
ReflectionHelpers.setField(
|
||||
activity,
|
||||
"currentPendingDispatchPlan",
|
||||
new JSONObject().put("planId", "dispatch-plan-1").put("status", "pending_user_confirmation")
|
||||
);
|
||||
|
||||
JSONObject rejectedPlan = new JSONObject()
|
||||
.put("planId", "dispatch-plan-1")
|
||||
.put("status", "rejected");
|
||||
JSONObject response = new JSONObject()
|
||||
.put("plan", rejectedPlan)
|
||||
.put("collaborationGate", new JSONObject()
|
||||
.put("isGroup", true)
|
||||
.put("collaborationMode", "approval_required")
|
||||
.put("requiresMasterAgentApproval", true)
|
||||
.put("approvalState", "rejected"));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"applyDispatchPlanActionResponse",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, response)
|
||||
);
|
||||
|
||||
assertEquals("rejected", ReflectionHelpers.getField(activity, "projectApprovalState"));
|
||||
assertEquals(null, ReflectionHelpers.getField(activity, "currentPendingDispatchPlan"));
|
||||
JSONObject storedRejected = ReflectionHelpers.getField(activity, "currentRejectedDispatchPlan");
|
||||
assertEquals("dispatch-plan-1", storedRejected.optString("planId"));
|
||||
}
|
||||
|
||||
private static JSONObject buildGroupProjectPayload() throws Exception {
|
||||
JSONObject threadMeta = new JSONObject()
|
||||
.put("threadId", "group-thread-3")
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { appendProjectMessage, readState } from "@/lib/boss-data";
|
||||
import { queueGroupDispatchPlan } from "@/lib/boss-master-agent";
|
||||
|
||||
function buildCollaborationGate(project?: {
|
||||
isGroup: boolean;
|
||||
collaborationMode: "development" | "approval_required";
|
||||
approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
|
||||
}) {
|
||||
return project
|
||||
? {
|
||||
isGroup: project.isGroup,
|
||||
collaborationMode: project.collaborationMode,
|
||||
requiresMasterAgentApproval: project.isGroup && project.collaborationMode === "approval_required",
|
||||
approvalState: project.approvalState,
|
||||
}
|
||||
: {
|
||||
isGroup: false,
|
||||
collaborationMode: "development" as const,
|
||||
requiresMasterAgentApproval: false,
|
||||
approvalState: "not_required" as const,
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchFailureNotice(error?: string) {
|
||||
switch (error) {
|
||||
case "GROUP_DISPATCH_TARGETS_REQUIRED":
|
||||
return "当前群聊里还没有可下发的真实线程,请先在群资料里重新添加线程后再试。";
|
||||
case "DISPATCH_TARGET_PROJECT_NOT_FOUND":
|
||||
return "当前群聊里有失效的线程引用,请重新整理群成员后再试。";
|
||||
default:
|
||||
return error ? `主 Agent 暂时无法重新生成推荐:${error}` : "主 Agent 暂时无法重新生成推荐,请稍后重试。";
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string; planId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId, planId } = await context.params;
|
||||
try {
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) {
|
||||
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
if (!project.isGroup) {
|
||||
return NextResponse.json({ ok: false, message: "PROJECT_NOT_GROUP_CHAT" }, { status: 400 });
|
||||
}
|
||||
|
||||
const plan = state.dispatchPlans.find((item) => item.planId === planId);
|
||||
if (!plan || plan.groupProjectId !== projectId) {
|
||||
return NextResponse.json({ ok: false, message: "DISPATCH_PLAN_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
if (plan.status !== "rejected") {
|
||||
return NextResponse.json({ ok: false, message: "DISPATCH_PLAN_NOT_REJECTED" }, { status: 400 });
|
||||
}
|
||||
|
||||
const pendingPlan = [...state.dispatchPlans]
|
||||
.filter(
|
||||
(item) => item.groupProjectId === projectId && item.status === "pending_user_confirmation",
|
||||
)
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
||||
if (pendingPlan) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
message: "当前还有一条主 Agent 推荐等待你确认,请先确认或拒绝后再继续。",
|
||||
pendingPlan,
|
||||
collaborationGate: buildCollaborationGate(project),
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const requestMessage = project.messages.find((message) => message.id === plan.requestMessageId);
|
||||
const requestText = requestMessage?.body?.trim() || plan.summary?.trim();
|
||||
if (!requestText) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "DISPATCH_PLAN_REQUEST_TEXT_REQUIRED" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const retryMessageId = `${plan.requestMessageId}:retry:${Date.now()}`;
|
||||
const recommendation = await queueGroupDispatchPlan({
|
||||
groupProjectId: projectId,
|
||||
requestMessageId: retryMessageId,
|
||||
requestText,
|
||||
requestedBy: session.account,
|
||||
});
|
||||
|
||||
if (!recommendation.ok) {
|
||||
await appendProjectMessage({
|
||||
projectId,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: dispatchFailureNotice(recommendation.error),
|
||||
kind: "system_notice",
|
||||
});
|
||||
}
|
||||
|
||||
const nextState = await readState();
|
||||
const nextProject = nextState.projects.find((item) => item.id === projectId);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
dispatchRecommendation: recommendation,
|
||||
dispatchPlan: recommendation.dispatchPlan,
|
||||
collaborationGate: buildCollaborationGate(nextProject),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { listDispatchPlansByProject, readState } from "@/lib/boss-data";
|
||||
import { latestPendingDispatchPlan } from "@/lib/dispatch-plan-ui";
|
||||
import { latestPendingDispatchPlan, latestRejectedDispatchPlan } from "@/lib/dispatch-plan-ui";
|
||||
import { formatTimestampLabel, getProjectDetailView } from "@/lib/boss-projections";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -29,6 +29,10 @@ export default async function ProjectChatPage({
|
||||
const pendingDispatchPlan = detail?.project.isGroup
|
||||
? latestPendingDispatchPlan(await listDispatchPlansByProject(projectId))
|
||||
: null;
|
||||
const rejectedDispatchPlan =
|
||||
detail?.project.isGroup && !pendingDispatchPlan
|
||||
? latestRejectedDispatchPlan(await listDispatchPlansByProject(projectId))
|
||||
: null;
|
||||
|
||||
if (!detail) notFound();
|
||||
|
||||
@@ -165,6 +169,18 @@ export default async function ProjectChatPage({
|
||||
}
|
||||
: null
|
||||
}
|
||||
initialRejectedDispatchPlan={
|
||||
rejectedDispatchPlan
|
||||
? {
|
||||
planId: rejectedDispatchPlan.planId,
|
||||
summary: rejectedDispatchPlan.summary,
|
||||
targets: (rejectedDispatchPlan.targets ?? []).map((target) => ({
|
||||
projectId: target.projectId,
|
||||
threadDisplayName: target.threadDisplayName,
|
||||
})),
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -819,9 +819,11 @@ type PendingDispatchPlanState = {
|
||||
export function ChatComposer({
|
||||
projectId,
|
||||
initialPendingDispatchPlan,
|
||||
initialRejectedDispatchPlan,
|
||||
}: {
|
||||
projectId: string;
|
||||
initialPendingDispatchPlan?: PendingDispatchPlanState | null;
|
||||
initialRejectedDispatchPlan?: PendingDispatchPlanState | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [value, setValue] = useState("");
|
||||
@@ -830,12 +832,16 @@ export function ChatComposer({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localPendingDispatchPlan, setLocalPendingDispatchPlan] =
|
||||
useState<PendingDispatchPlanState | null>(null);
|
||||
const [localRejectedDispatchPlan, setLocalRejectedDispatchPlan] =
|
||||
useState<PendingDispatchPlanState | null>(null);
|
||||
const [dismissedPendingPlanId, setDismissedPendingPlanId] = useState<string | null>(null);
|
||||
const pendingDispatchPlan =
|
||||
localPendingDispatchPlan ??
|
||||
(initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId
|
||||
? initialPendingDispatchPlan
|
||||
: null);
|
||||
const rejectedDispatchPlan =
|
||||
pendingDispatchPlan ? null : localRejectedDispatchPlan ?? initialRejectedDispatchPlan ?? null;
|
||||
|
||||
async function confirmDispatchPlan() {
|
||||
if (!pendingDispatchPlan) return;
|
||||
@@ -863,12 +869,102 @@ export function ChatComposer({
|
||||
}
|
||||
const executionCount = result.executions?.length ?? extractApprovedTargetProjectIds(pendingDispatchPlan).length;
|
||||
setLocalPendingDispatchPlan(null);
|
||||
setLocalRejectedDispatchPlan(null);
|
||||
setDismissedPendingPlanId(pendingDispatchPlan.planId);
|
||||
setMessageTone("success");
|
||||
setMessage(`已确认下发到 ${executionCount} 个线程。`);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function retryDispatchPlan() {
|
||||
if (!rejectedDispatchPlan) return;
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/v1/projects/${projectId}/dispatch-plans/${rejectedDispatchPlan.planId}/retry`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
const result = (await response.json()) as {
|
||||
ok: boolean;
|
||||
dispatchPlan?: {
|
||||
planId: string;
|
||||
summary?: string;
|
||||
targets?: Array<{ projectId: string; threadDisplayName: string }>;
|
||||
} | null;
|
||||
collaborationGate?: {
|
||||
requiresMasterAgentApproval?: boolean;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
setLoading(false);
|
||||
if (!result.ok) {
|
||||
setMessageTone("error");
|
||||
setMessage(result.message ?? "重新生成推荐失败,请稍后重试。");
|
||||
return;
|
||||
}
|
||||
setLocalRejectedDispatchPlan(null);
|
||||
setLocalPendingDispatchPlan(
|
||||
result.dispatchPlan
|
||||
? {
|
||||
planId: result.dispatchPlan.planId,
|
||||
summary: result.dispatchPlan.summary,
|
||||
targets: result.dispatchPlan.targets ?? [],
|
||||
}
|
||||
: null,
|
||||
);
|
||||
setDismissedPendingPlanId(null);
|
||||
setMessageTone("success");
|
||||
setMessage(
|
||||
result.collaborationGate?.requiresMasterAgentApproval
|
||||
? "主 Agent 已重新生成推荐,等待你确认下发。"
|
||||
: "主 Agent 已重新生成推荐。",
|
||||
);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function rejectDispatchPlan() {
|
||||
if (!pendingDispatchPlan) return;
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/v1/projects/${projectId}/dispatch-plans/${pendingDispatchPlan.planId}/reject`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
const result = (await response.json()) as {
|
||||
ok: boolean;
|
||||
plan?: {
|
||||
planId: string;
|
||||
summary?: string;
|
||||
targets?: Array<{ projectId: string; threadDisplayName: string }>;
|
||||
} | null;
|
||||
message?: string;
|
||||
};
|
||||
setLoading(false);
|
||||
if (!result.ok) {
|
||||
setMessageTone("error");
|
||||
setMessage(result.message ?? "拒绝失败,请稍后重试。");
|
||||
return;
|
||||
}
|
||||
setLocalPendingDispatchPlan(null);
|
||||
setLocalRejectedDispatchPlan(
|
||||
result.plan
|
||||
? {
|
||||
planId: result.plan.planId,
|
||||
summary: result.plan.summary,
|
||||
targets: result.plan.targets ?? pendingDispatchPlan.targets,
|
||||
}
|
||||
: pendingDispatchPlan,
|
||||
);
|
||||
setDismissedPendingPlanId(pendingDispatchPlan.planId);
|
||||
setMessageTone("success");
|
||||
setMessage("已拒绝主 Agent 推荐。");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function send(kind: "text" | "voice_intent" | "image_intent" | "video_intent") {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/v1/projects/${projectId}/messages`, {
|
||||
@@ -907,6 +1003,7 @@ export function ChatComposer({
|
||||
summary: result.dispatchPlan.summary,
|
||||
targets: result.dispatchPlan.targets ?? [],
|
||||
});
|
||||
setLocalRejectedDispatchPlan(null);
|
||||
setDismissedPendingPlanId(null);
|
||||
setMessage(
|
||||
result.collaborationGate?.requiresMasterAgentApproval
|
||||
@@ -997,6 +1094,14 @@ export function ChatComposer({
|
||||
>
|
||||
确认下发
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => void rejectDispatchPlan()}
|
||||
className="rounded-full border border-[#F0B5B5] px-4 py-2 text-[13px] font-semibold text-[#CF1322]"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
@@ -1011,6 +1116,23 @@ export function ChatComposer({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{rejectedDispatchPlan ? (
|
||||
<div className="mt-3 rounded-2xl border border-[#F3D19C] bg-[#FFF7E6] px-4 py-4 text-[12px] leading-6 text-[#8D5D00]">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">上次推荐已拒绝</div>
|
||||
<div className="mt-2 whitespace-pre-line">{summarizeDispatchPlan(rejectedDispatchPlan)}</div>
|
||||
<div className="mt-2">如果还想继续当前协作,可以直接重新生成推荐,不用把整条需求重新打一遍。</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => void retryDispatchPlan()}
|
||||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
|
||||
>
|
||||
重新生成推荐
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,6 +192,37 @@ export function DeviceImportDraftManager({
|
||||
);
|
||||
}
|
||||
|
||||
async function clearSelection() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft/select`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ selectedCandidateIds: [] }),
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
draft?: DeviceImportDraft;
|
||||
};
|
||||
if (!result.ok) {
|
||||
setFeedback({ tone: "error", text: result.message ?? "清空勾选失败" });
|
||||
return;
|
||||
}
|
||||
setDraft(result.draft ?? draft);
|
||||
setResolution(null);
|
||||
setSelectedCandidateIds([]);
|
||||
setFeedback({ tone: "success", text: "已清空当前勾选,你可以重新选择要导入的线程。" });
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
tone: "error",
|
||||
text: error instanceof Error ? error.message : "清空勾选失败",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewSelection() {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -209,6 +240,9 @@ export function DeviceImportDraftManager({
|
||||
setFeedback({ tone: "error", text: selectResult.message ?? "勾选保存失败" });
|
||||
return;
|
||||
}
|
||||
setDraft(selectResult.draft ?? draft);
|
||||
setResolution(null);
|
||||
setSelectedCandidateIds(selectResult.draft?.selectedCandidateIds ?? selectedCandidateIds);
|
||||
|
||||
const reviewResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/review`, {
|
||||
method: "POST",
|
||||
@@ -222,7 +256,7 @@ export function DeviceImportDraftManager({
|
||||
resolution?: DeviceImportResolution;
|
||||
};
|
||||
if (!reviewResult.ok) {
|
||||
setFeedback({ tone: "error", text: reviewResult.message ?? "导入建议生成失败" });
|
||||
setFeedback({ tone: "error", text: reviewResult.message ?? "导入建议生成失败,已保留当前勾选。" });
|
||||
return;
|
||||
}
|
||||
setDraft(reviewResult.draft ?? selectResult.draft ?? null);
|
||||
@@ -290,7 +324,7 @@ export function DeviceImportDraftManager({
|
||||
<div className="min-w-0">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">导入 Codex 项目</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">
|
||||
{deviceName ?? deviceId} 完成首次 heartbeat 后,这里会出现可导入项目和线程。
|
||||
{deviceName ?? deviceId} 完成首次 heartbeat 后,这里会出现可导入项目和线程;如果暂时为空,先刷新等待下一次发现。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -319,6 +353,12 @@ export function DeviceImportDraftManager({
|
||||
当前状态:{draft.status}
|
||||
<br />
|
||||
已勾选:{selectedCandidateIds.length}
|
||||
{draft.status === "pending_candidates" ? (
|
||||
<>
|
||||
<br />
|
||||
设备已在线,但还没有发现可导入线程。
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
@@ -416,10 +456,18 @@ export function DeviceImportDraftManager({
|
||||
>
|
||||
{draft?.status === "resolved" || draft?.status === "applied" ? "重新生成导入建议" : "生成导入建议"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clearSelection()}
|
||||
disabled={loading || selectedCandidateIds.length === 0}
|
||||
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A] disabled:text-[#B8B8B8]"
|
||||
>
|
||||
清空勾选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void applyResolution()}
|
||||
disabled={loading || !resolution || draft?.status === "applied"}
|
||||
disabled={loading || !resolution || draft?.status !== "resolved"}
|
||||
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A] disabled:text-[#B8B8B8]"
|
||||
>
|
||||
{draft?.status === "applied" ? "已导入" : "应用导入"}
|
||||
|
||||
@@ -5564,42 +5564,86 @@ function upsertDeviceImportDraftFromHeartbeat(
|
||||
candidates: DeviceImportCandidate[];
|
||||
},
|
||||
) {
|
||||
const existing = state.deviceImportDrafts.find((item) => item.deviceId === payload.deviceId);
|
||||
if (payload.candidates.length === 0) {
|
||||
return null;
|
||||
if (existing?.status === "applied" && existing.appliedProjectNames.length > 0) {
|
||||
return existing;
|
||||
}
|
||||
const waitingDraft = normalizeDeviceImportDraft({
|
||||
draftId: existing?.draftId ?? randomToken("import-draft"),
|
||||
deviceId: payload.deviceId,
|
||||
enrollmentId: payload.enrollmentId ?? existing?.enrollmentId,
|
||||
status: "pending_candidates",
|
||||
candidates: [],
|
||||
selectedCandidateIds: [],
|
||||
appliedProjectNames: [],
|
||||
createdAt: existing?.createdAt ?? nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
}, existing);
|
||||
waitingDraft.reviewedAt = undefined;
|
||||
waitingDraft.reviewedBy = undefined;
|
||||
waitingDraft.resolutionId = undefined;
|
||||
state.deviceImportResolutions = state.deviceImportResolutions.filter(
|
||||
(item) => item.draftId !== waitingDraft.draftId,
|
||||
);
|
||||
state.deviceImportDrafts = [
|
||||
waitingDraft,
|
||||
...state.deviceImportDrafts.filter((item) => item.draftId !== waitingDraft.draftId),
|
||||
];
|
||||
return waitingDraft;
|
||||
}
|
||||
|
||||
const existing = state.deviceImportDrafts.find((item) => item.deviceId === payload.deviceId);
|
||||
const selectedCandidateIds = dedupeStrings(
|
||||
(existing?.selectedCandidateIds ?? []).filter((candidateId) =>
|
||||
payload.candidates.some((candidate) => candidate.candidateId === candidateId),
|
||||
),
|
||||
);
|
||||
const previousCandidateIds = existing?.candidates.map((candidate) => candidate.candidateId) ?? [];
|
||||
const nextCandidateIds = payload.candidates.map((candidate) => candidate.candidateId);
|
||||
const selectionChanged =
|
||||
!sameStringSet(existing?.selectedCandidateIds ?? [], selectedCandidateIds) ||
|
||||
!sameStringSet(previousCandidateIds, nextCandidateIds);
|
||||
const keepAppliedState =
|
||||
!selectionChanged &&
|
||||
existing?.status === "applied" &&
|
||||
Boolean(existing.resolutionId) &&
|
||||
selectedCandidateIds.length > 0;
|
||||
const keepResolvedState =
|
||||
!selectionChanged &&
|
||||
selectedCandidateIds.length > 0 &&
|
||||
Boolean(existing?.resolutionId);
|
||||
|
||||
const nextDraft = normalizeDeviceImportDraft({
|
||||
draftId: existing?.draftId ?? randomToken("import-draft"),
|
||||
deviceId: payload.deviceId,
|
||||
enrollmentId: payload.enrollmentId ?? existing?.enrollmentId,
|
||||
status:
|
||||
existing?.status === "applied" && existing.resolutionId && selectedCandidateIds.length > 0
|
||||
keepAppliedState
|
||||
? "applied"
|
||||
: selectedCandidateIds.length > 0
|
||||
? existing?.resolutionId
|
||||
? keepResolvedState
|
||||
? "resolved"
|
||||
: "pending_resolution"
|
||||
: "pending_selection",
|
||||
candidates: payload.candidates,
|
||||
selectedCandidateIds,
|
||||
appliedProjectNames:
|
||||
existing?.status === "applied" && selectedCandidateIds.length > 0
|
||||
keepAppliedState
|
||||
? existing.appliedProjectNames
|
||||
: [],
|
||||
createdAt: existing?.createdAt ?? nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
reviewedAt: existing?.reviewedAt,
|
||||
reviewedBy: existing?.reviewedBy,
|
||||
resolutionId: existing?.resolutionId,
|
||||
reviewedAt: keepResolvedState || keepAppliedState ? existing?.reviewedAt : undefined,
|
||||
reviewedBy: keepResolvedState || keepAppliedState ? existing?.reviewedBy : undefined,
|
||||
resolutionId: keepResolvedState || keepAppliedState ? existing?.resolutionId : undefined,
|
||||
}, existing);
|
||||
|
||||
if (!keepResolvedState && !keepAppliedState) {
|
||||
state.deviceImportResolutions = state.deviceImportResolutions.filter(
|
||||
(item) => item.draftId !== nextDraft.draftId,
|
||||
);
|
||||
}
|
||||
|
||||
state.deviceImportDrafts = [
|
||||
nextDraft,
|
||||
...state.deviceImportDrafts.filter((item) => item.draftId !== nextDraft.draftId),
|
||||
@@ -5652,7 +5696,8 @@ export async function upsertDeviceHeartbeat(payload: {
|
||||
suggestedImport: candidate.suggestedImport ?? true,
|
||||
}),
|
||||
);
|
||||
const shouldAutoImportLegacyProjects = normalizedCandidates.length === 0;
|
||||
const reportedProjectCandidates = Array.isArray(payload.projectCandidates);
|
||||
const shouldAutoImportLegacyProjects = !reportedProjectCandidates && normalizedCandidates.length === 0;
|
||||
|
||||
let device = existingDevice;
|
||||
if (!device) {
|
||||
@@ -5969,11 +6014,8 @@ export async function selectDeviceImportCandidates(input: {
|
||||
const nextSelected = dedupeStrings(input.selectedCandidateIds).filter((candidateId) =>
|
||||
availableCandidateIds.has(candidateId),
|
||||
);
|
||||
if (nextSelected.length === 0) {
|
||||
throw new Error("DEVICE_IMPORT_SELECTION_REQUIRED");
|
||||
}
|
||||
draft.selectedCandidateIds = nextSelected;
|
||||
draft.status = "pending_resolution";
|
||||
draft.status = nextSelected.length > 0 ? "pending_resolution" : "pending_selection";
|
||||
draft.appliedProjectNames = [];
|
||||
draft.updatedAt = nowIso();
|
||||
draft.reviewedBy = input.selectedBy;
|
||||
@@ -6202,6 +6244,23 @@ function applyDeviceImportResolutionInState(
|
||||
const device = state.devices.find((item) => item.id === input.deviceId);
|
||||
if (!device) throw new Error("DEVICE_NOT_FOUND");
|
||||
|
||||
if (draft.status === "applied" && resolution.status === "applied") {
|
||||
const importedProjects = state.projects.filter(
|
||||
(project) =>
|
||||
!project.isGroup &&
|
||||
project.deviceIds.includes(device.id) &&
|
||||
draft.appliedProjectNames.includes(project.name),
|
||||
);
|
||||
return {
|
||||
draft: { ...draft },
|
||||
resolution: { ...resolution },
|
||||
importedProjects: importedProjects.map((project) => ({ ...project })),
|
||||
};
|
||||
}
|
||||
if (draft.status !== "resolved") {
|
||||
throw new Error("DEVICE_IMPORT_RESOLUTION_STALE");
|
||||
}
|
||||
|
||||
const selectedCandidates = draft.candidates.filter((candidate) =>
|
||||
draft.selectedCandidateIds.includes(candidate.candidateId),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,10 @@ export function latestPendingDispatchPlan(plans: DispatchPlanUiPayload[] | null
|
||||
return (plans ?? []).find((plan) => plan.status === "pending_user_confirmation") ?? null;
|
||||
}
|
||||
|
||||
export function latestRejectedDispatchPlan(plans: DispatchPlanUiPayload[] | null | undefined) {
|
||||
return (plans ?? []).find((plan) => plan.status === "rejected") ?? null;
|
||||
}
|
||||
|
||||
export function summarizeDispatchPlan(plan: DispatchPlanUiPayload | null | undefined) {
|
||||
if (!plan) {
|
||||
return "主 Agent 暂未生成推荐线程。";
|
||||
|
||||
@@ -426,6 +426,118 @@ test("device import apply is idempotent and heartbeat preserves applied status",
|
||||
assert.equal(appliedDraft?.status, "applied", "later heartbeats should not regress applied drafts");
|
||||
});
|
||||
|
||||
test("clearing device import selection resets draft back to pending_selection and drops old resolution", async () => {
|
||||
await setup();
|
||||
|
||||
const enrollmentResponse = await createEnrollmentRoute(
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
|
||||
name: "Review Mac",
|
||||
avatar: "R",
|
||||
account: "17600003315",
|
||||
endpoint: "mac://review.local",
|
||||
note: "selection reset",
|
||||
}),
|
||||
);
|
||||
assert.equal(enrollmentResponse.status, 200);
|
||||
const enrollmentPayload = (await enrollmentResponse.json()) as {
|
||||
enrollment: { pairingCode: string };
|
||||
device: { id: string };
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await deviceHeartbeatRoute(
|
||||
new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
deviceId: enrollmentPayload.device.id,
|
||||
pairingCode: enrollmentPayload.enrollment.pairingCode,
|
||||
name: "Review Mac",
|
||||
avatar: "R",
|
||||
account: "17600003315",
|
||||
status: "online",
|
||||
quota5h: 66,
|
||||
quota7d: 79,
|
||||
projects: [],
|
||||
endpoint: "mac://review.local",
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: "回归目录",
|
||||
folderRef: "review-folder",
|
||||
threadId: "thread-review-1",
|
||||
threadDisplayName: "回归线程一",
|
||||
codexFolderRef: "review-folder",
|
||||
codexThreadRef: "thread-review-1",
|
||||
lastActiveAt: "2026-03-30T11:00:00+08:00",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
|
||||
const draftResponse = await getImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft`,
|
||||
"GET",
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
);
|
||||
const draftPayload = (await draftResponse.json()) as {
|
||||
draft: { candidates: Array<{ candidateId: string }> };
|
||||
};
|
||||
const selectedCandidateIds = draftPayload.draft.candidates.map((candidate) => candidate.candidateId);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await selectImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/select`,
|
||||
"POST",
|
||||
{ selectedCandidateIds },
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
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 }) },
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
|
||||
const clearSelectionResponse = await selectImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/select`,
|
||||
"POST",
|
||||
{ selectedCandidateIds: [] },
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
);
|
||||
assert.equal(clearSelectionResponse.status, 200);
|
||||
|
||||
const nextState = await readState();
|
||||
const draft = nextState.deviceImportDrafts.find((item) => item.deviceId === enrollmentPayload.device.id);
|
||||
const resolution = nextState.deviceImportResolutions.find((item) => item.deviceId === enrollmentPayload.device.id);
|
||||
assert.equal(draft?.status, "pending_selection");
|
||||
assert.deepEqual(draft?.selectedCandidateIds, []);
|
||||
assert.equal(draft?.resolutionId, undefined);
|
||||
assert.equal(resolution, undefined);
|
||||
});
|
||||
|
||||
test("device import routes reject unrelated logged-in members", async () => {
|
||||
await setup();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ let postMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/mes
|
||||
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"];
|
||||
let retryDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route"))["POST"];
|
||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||
let createProjectGroupChat: (typeof import("../src/lib/boss-data"))["createProjectGroupChat"];
|
||||
let isDispatchableThreadProject: (typeof import("../src/lib/boss-data"))["isDispatchableThreadProject"];
|
||||
@@ -26,11 +27,12 @@ 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, data, auth] = await Promise.all([
|
||||
const [messageModule, plansModule, confirmModule, rejectModule, retryModule, data, auth] = await Promise.all([
|
||||
import("../src/app/api/v1/projects/[projectId]/messages/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"),
|
||||
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route.ts"),
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
]);
|
||||
@@ -39,6 +41,7 @@ async function setup() {
|
||||
getDispatchPlansRoute = plansModule.GET;
|
||||
confirmDispatchPlanRoute = confirmModule.POST;
|
||||
rejectDispatchPlanRoute = rejectModule.POST;
|
||||
retryDispatchPlanRoute = retryModule.POST;
|
||||
createAuthSession = data.createAuthSession;
|
||||
createProjectGroupChat = data.createProjectGroupChat;
|
||||
isDispatchableThreadProject = data.isDispatchableThreadProject;
|
||||
@@ -317,3 +320,65 @@ test("rejecting a dispatch plan marks approval_required groups as rejected and w
|
||||
);
|
||||
assert.ok(notice, "expected rejection notice in group chat");
|
||||
});
|
||||
|
||||
test("retrying a rejected dispatch plan creates a fresh pending recommendation and resets approval gate", async () => {
|
||||
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
|
||||
|
||||
const state = await readState();
|
||||
await writeState({
|
||||
...state,
|
||||
projects: state.projects.map((project) =>
|
||||
project.id === groupProject.id
|
||||
? {
|
||||
...project,
|
||||
collaborationMode: "approval_required" as const,
|
||||
approvalState: "pending_user" as const,
|
||||
}
|
||||
: project,
|
||||
),
|
||||
});
|
||||
|
||||
const rejectResponse = await rejectDispatchPlanRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/reject`,
|
||||
"POST",
|
||||
{},
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
|
||||
);
|
||||
assert.equal(rejectResponse.status, 200);
|
||||
|
||||
const retryResponse = await retryDispatchPlanRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/retry`,
|
||||
"POST",
|
||||
{},
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
|
||||
);
|
||||
assert.equal(retryResponse.status, 200);
|
||||
|
||||
const retryPayload = (await retryResponse.json()) as {
|
||||
ok: boolean;
|
||||
dispatchPlan: { planId: string; status: string; requestMessageId: string } | null;
|
||||
collaborationGate: {
|
||||
approvalState: string;
|
||||
requiresMasterAgentApproval: boolean;
|
||||
collaborationMode: string;
|
||||
};
|
||||
};
|
||||
assert.equal(retryPayload.ok, true);
|
||||
assert.ok(retryPayload.dispatchPlan, "expected a fresh dispatch recommendation");
|
||||
assert.notEqual(retryPayload.dispatchPlan?.planId, dispatchPlan.planId);
|
||||
assert.equal(retryPayload.dispatchPlan?.status, "pending_user_confirmation");
|
||||
assert.match(retryPayload.dispatchPlan?.requestMessageId ?? "", /:retry:/);
|
||||
assert.equal(retryPayload.collaborationGate.collaborationMode, "approval_required");
|
||||
assert.equal(retryPayload.collaborationGate.requiresMasterAgentApproval, true);
|
||||
assert.equal(retryPayload.collaborationGate.approvalState, "pending_user");
|
||||
|
||||
const nextState = await readState();
|
||||
const refreshedPlan = nextState.dispatchPlans.find((plan) => plan.planId === retryPayload.dispatchPlan?.planId);
|
||||
assert.ok(refreshedPlan, "expected retried dispatch plan in state");
|
||||
const nextGroupProject = nextState.projects.find((project) => project.id === groupProject.id);
|
||||
assert.equal(nextGroupProject?.approvalState, "pending_user");
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
extractApprovedTargetProjectIds,
|
||||
latestRejectedDispatchPlan,
|
||||
latestPendingDispatchPlan,
|
||||
summarizeDispatchPlan,
|
||||
} from "@/lib/dispatch-plan-ui";
|
||||
@@ -54,3 +55,27 @@ test("latestPendingDispatchPlan returns the latest waiting confirmation item", (
|
||||
targets: [{ projectId: "p2", threadDisplayName: "设备接入线程" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("latestRejectedDispatchPlan returns the latest rejected item", () => {
|
||||
const plan = latestRejectedDispatchPlan([
|
||||
{
|
||||
planId: "dispatch-plan-1",
|
||||
status: "dispatched",
|
||||
summary: "已完成的推荐",
|
||||
targets: [{ projectId: "p1", threadDisplayName: "Boss UI 主线程" }],
|
||||
},
|
||||
{
|
||||
planId: "dispatch-plan-3",
|
||||
status: "rejected",
|
||||
summary: "已拒绝的推荐",
|
||||
targets: [{ projectId: "p3", threadDisplayName: "调度修复线程" }],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(plan, {
|
||||
planId: "dispatch-plan-3",
|
||||
status: "rejected",
|
||||
summary: "已拒绝的推荐",
|
||||
targets: [{ projectId: "p3", threadDisplayName: "调度修复线程" }],
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user