diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java index 188e3e2..73e8dea 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java @@ -81,6 +81,48 @@ public final class ProjectChatUiState { return nearBottom || forced; } + public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) { + if (conflict == null) { + return "当前线程命中冲突保护"; + } + if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) { + return "当前项目默认先走 GUI"; + } + return "当前项目已命中并发保护"; + } + + public static String threadExecutionConflictSummary(@Nullable JSONObject conflict) { + if (conflict == null) { + return "当前线程命中了 GUI / CLI 冲突保护,请先确认是否继续。"; + } + String projectName = conflict.optString("projectName", "当前项目"); + String deviceName = conflict.optString("deviceName", "当前设备"); + if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) { + return deviceName + " 现在默认优先 GUI。要让主 Agent 继续通过 CLI 推进 " + projectName + ",需要你先对这个项目放行;这个选择只对这个项目生效。"; + } + return projectName + " 最近检测到 GUI / CLI 同时活动,当前先按禁止处理。这个提示只影响这个项目;你可以临时放行,或者把这个项目永久放行。"; + } + + public static String labelForThreadExecutionConflictDecision(@Nullable String decision) { + if ("allow_once".equals(decision)) { + return "允许本次"; + } + if ("allow_always".equals(decision)) { + return "永久放行"; + } + return "禁止"; + } + + public static String summarizeThreadExecutionConflictDecisionResult(@Nullable String decision) { + if ("allow_once".equals(decision)) { + return "已允许本次,继续发送中…"; + } + if ("allow_always".equals(decision)) { + return "已对当前项目永久放行,继续发送中…"; + } + return "已保持禁止,这次消息没有发出。"; + } + public static SelectionState emptySelection() { return new SelectionState(new LinkedHashSet<>()); } diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index 3c42400..5d97dda 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -649,7 +649,20 @@ public class ProjectDetailActivity extends BossScreenActivity { executor.execute(() -> { try { BossApiClient.ApiResponse response = apiClient.sendProjectMessage(projectId, body, kind); + JSONObject executionConflict = response.json.optJSONObject("executionConflict"); if (!response.ok()) { + if (response.statusCode == 409 + && "THREAD_EXECUTION_CONFLICT".equals(response.json.optString("code", "")) + && executionConflict != null) { + runOnUiThread(() -> { + composerSending = false; + setRefreshing(false); + removePendingOutgoingBubble(); + updateComposerSendButtonState(); + showThreadExecutionConflictDialog(executionConflict, body, kind); + }); + return; + } throw new IllegalStateException(response.message()); } JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan"); @@ -702,6 +715,85 @@ public class ProjectDetailActivity extends BossScreenActivity { }); } + private void showThreadExecutionConflictDialog(JSONObject executionConflict, String body, String kind) { + String preferredMode = "gui".equals(executionConflict.optString("preferredExecutionMode", "cli")) + ? "GUI" + : "CLI"; + String deviceName = executionConflict.optString("deviceName", "当前设备"); + String folderKey = executionConflict.optString("folderKey", "").trim(); + StringBuilder messageBuilder = new StringBuilder(ProjectChatUiState.threadExecutionConflictSummary(executionConflict)) + .append("\n\n设备:") + .append(deviceName) + .append(" · 默认模式:") + .append(preferredMode); + if (!folderKey.isEmpty()) { + messageBuilder.append("\n范围:").append(folderKey); + } + new AlertDialog.Builder(this) + .setTitle(ProjectChatUiState.threadExecutionConflictTitle(executionConflict)) + .setMessage(messageBuilder.toString()) + .setNegativeButton( + ProjectChatUiState.labelForThreadExecutionConflictDecision("forbid"), + (dialog, which) -> { + showMessage(ProjectChatUiState.summarizeThreadExecutionConflictDecisionResult("forbid")); + updateComposerSendButtonState(); + } + ) + .setNeutralButton( + ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_once"), + (dialog, which) -> applyThreadExecutionConflictDecision(executionConflict, "allow_once", body, kind) + ) + .setPositiveButton( + ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_always"), + (dialog, which) -> applyThreadExecutionConflictDecision(executionConflict, "allow_always", body, kind) + ) + .show(); + } + + private void applyThreadExecutionConflictDecision( + JSONObject executionConflict, + String decision, + String body, + String kind + ) { + composerSending = true; + updateComposerSendButtonState(); + setRefreshing(true); + executor.execute(() -> { + try { + String folderKey = executionConflict.optString("folderKey", "").trim(); + BossApiClient.ApiResponse response = apiClient.updateProjectConflictDecision( + executionConflict.optString("deviceId", ""), + executionConflict.optString("projectId", ""), + folderKey.isEmpty() ? null : folderKey, + decision + ); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + runOnUiThread(() -> { + showMessage(ProjectChatUiState.summarizeThreadExecutionConflictDecisionResult(decision)); + if ("forbid".equals(decision)) { + composerSending = false; + setRefreshing(false); + updateComposerSendButtonState(); + return; + } + composerSending = false; + updateComposerSendButtonState(); + sendProjectMessage(kind, body); + }); + } catch (Exception error) { + runOnUiThread(() -> { + composerSending = false; + setRefreshing(false); + updateComposerSendButtonState(); + showMessage("冲突放行设置失败:" + error.getMessage()); + }); + } + }); + } + private void uploadAttachment(AttachmentComposerState.PendingAttachment attachment) { composerSending = true; updateComposerSendButtonState(); diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java index fb107be..93fca8b 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java @@ -249,4 +249,27 @@ public class ProjectChatUiStateTest { assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-thread-1")); assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "")); } + + @Test + public void threadExecutionConflictCopyExplainsPreferredGuiModeAsProjectScoped() throws Exception { + JSONObject conflict = new JSONObject() + .put("projectName", "Boss UI 主线程") + .put("deviceName", "Mac Studio") + .put("reason", "preferred_gui_mode"); + + assertEquals("当前项目默认先走 GUI", ProjectChatUiState.threadExecutionConflictTitle(conflict)); + assertTrue(ProjectChatUiState.threadExecutionConflictSummary(conflict).contains("只对这个项目生效")); + } + + @Test + public void threadExecutionConflictCopyExplainsForbidAsProjectOnly() throws Exception { + JSONObject conflict = new JSONObject() + .put("projectName", "Boss UI 主线程") + .put("reason", "project_conflict_forbid"); + + assertEquals("当前项目已命中并发保护", ProjectChatUiState.threadExecutionConflictTitle(conflict)); + assertTrue(ProjectChatUiState.threadExecutionConflictSummary(conflict).contains("只影响这个项目")); + assertEquals("允许本次", ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_once")); + assertEquals("永久放行", ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_always")); + } } diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts index 351ab02..8df3ff4 100644 --- a/src/app/api/v1/projects/[projectId]/messages/route.ts +++ b/src/app/api/v1/projects/[projectId]/messages/route.ts @@ -2,10 +2,12 @@ import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { appendProjectMessage, buildCollaborationGate, readState } from "@/lib/boss-data"; import { + getThreadConversationExecutionConflict, queueGroupDispatchPlan, queueThreadConversationReplyTask, replyToMasterAgentUserMessage, shouldRecommendMasterAgentDispatchPlan, + ThreadConversationExecutionConflictError, } from "@/lib/boss-master-agent"; import { evaluatePermissionPolicy } from "@/lib/execution/permission-policy"; @@ -26,6 +28,8 @@ function threadConversationFailureMessage(error?: string) { return "当前线程还没有绑定真实 Codex 线程,请先重新导入该线程后再试。"; case "THREAD_TARGET_DEVICE_OFFLINE": return "当前线程所在设备不在线,请先让对应设备上线后再试。"; + case "THREAD_EXECUTION_CONFLICT": + return "当前线程命中了 GUI / CLI 冲突保护,请先确认本项目是否放行后再继续发送。"; default: return error ?? "UNKNOWN_ERROR"; } @@ -80,6 +84,27 @@ export async function POST( ); } + const singleThreadExecutionConflict = + project && + projectId !== "master-agent" && + !project.isGroup && + (body.kind ?? "text") === "text" && + (body.body ?? "").trim().length > 0 + ? await getThreadConversationExecutionConflict(projectId) + : null; + + if (singleThreadExecutionConflict) { + return NextResponse.json( + { + ok: false, + code: "THREAD_EXECUTION_CONFLICT", + message: threadConversationFailureMessage("THREAD_EXECUTION_CONFLICT"), + executionConflict: singleThreadExecutionConflict, + }, + { status: 409 }, + ); + } + const message = await appendProjectMessage({ projectId, senderLabel: session.displayName || "你", @@ -209,6 +234,17 @@ export async function POST( collaborationGate, }); } catch (error) { + if (error instanceof ThreadConversationExecutionConflictError) { + return NextResponse.json( + { + ok: false, + code: error.message, + message: threadConversationFailureMessage(error.message), + executionConflict: error.conflict, + }, + { status: 409 }, + ); + } const reason = error instanceof Error ? error.message : "UNKNOWN_ERROR"; return NextResponse.json( { diff --git a/src/components/app-ui.tsx b/src/components/app-ui.tsx index f94041d..48af8bf 100644 --- a/src/components/app-ui.tsx +++ b/src/components/app-ui.tsx @@ -21,6 +21,15 @@ import { summarizeDispatchPlanCompact, summarizeDispatchPlanLightTitle, } from "@/lib/dispatch-plan-ui"; +import type { + ThreadConversationExecutionConflict, + ThreadConversationExecutionConflictAction, +} from "@/lib/thread-execution-conflict"; +import { + describeThreadConversationExecutionConflict, + labelForThreadConversationExecutionConflictDecision, + summarizeThreadConversationExecutionDecisionResult, +} from "@/lib/thread-execution-conflict-ui"; import type { Device, DeviceEnrollment, @@ -1195,6 +1204,7 @@ export function ChatComposer({ initialLightDispatchReminderEnabled?: boolean; }) { const router = useRouter(); + type ComposerMessageKind = "text" | "voice_intent" | "image_intent" | "video_intent"; const [value, setValue] = useState(""); const [message, setMessage] = useState(""); const [messageTone, setMessageTone] = useState<"success" | "error">("success"); @@ -1207,6 +1217,11 @@ export function ChatComposer({ const [lightDispatchReminderEnabled, setLightDispatchReminderEnabled] = useState( initialLightDispatchReminderEnabled, ); + const [threadExecutionConflict, setThreadExecutionConflict] = useState<{ + conflict: ThreadConversationExecutionConflict; + draftBody: string; + kind: ComposerMessageKind; + } | null>(null); const pendingDispatchPlan = localPendingDispatchPlan ?? (initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId @@ -1214,6 +1229,9 @@ export function ChatComposer({ : null); const rejectedDispatchPlan = pendingDispatchPlan ? null : localRejectedDispatchPlan ?? initialRejectedDispatchPlan ?? null; + const threadExecutionConflictDescription = threadExecutionConflict + ? describeThreadConversationExecutionConflict(threadExecutionConflict.conflict) + : null; async function confirmDispatchPlan(rememberLightReminder = false) { if (!pendingDispatchPlan) return; @@ -1352,16 +1370,25 @@ export function ChatComposer({ router.refresh(); } - async function send(kind: "text" | "voice_intent" | "image_intent" | "video_intent") { + async function send( + kind: ComposerMessageKind, + options?: { + draftBody?: string; + }, + ) { + const draftBody = kind === "text" ? (options?.draftBody ?? value).trim() : ""; + if (kind === "text" && !draftBody) { + return; + } setLoading(true); const response = await fetch(`/api/v1/projects/${projectId}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ body: kind === "text" ? value : undefined, kind }), + body: JSON.stringify({ body: kind === "text" ? draftBody : undefined, kind }), }); const result = (await response.json()) as { ok: boolean; - message?: { body: string }; + message?: { body: string } | string; dispatchPlan?: { planId: string; summary?: string; @@ -1371,17 +1398,30 @@ export function ChatComposer({ requiresMasterAgentApproval?: boolean; lightDispatchReminderEnabled?: boolean; }; + code?: string; + executionConflict?: ThreadConversationExecutionConflict; messageText?: string; }; setLoading(false); + if (!result.ok && response.status === 409 && result.code === "THREAD_EXECUTION_CONFLICT" && result.executionConflict) { + setThreadExecutionConflict({ + conflict: result.executionConflict, + draftBody, + kind, + }); + setMessageTone("error"); + setMessage(typeof result.message === "string" ? result.message : "当前线程命中了 GUI / CLI 冲突保护。"); + return; + } if (result.ok) { + setThreadExecutionConflict(null); void sendAppLog({ deviceId: boundDeviceIdFromDom(), projectId, level: "info", category: "chat.message_sent", message: - kind === "text" ? `已发送文本消息:${value.trim() || "空文本"}` : `已发送 ${kind} 意图消息。`, + kind === "text" ? `已发送文本消息:${draftBody || "空文本"}` : `已发送 ${kind} 意图消息。`, mirrorToMaster: false, }); setValue(""); @@ -1419,7 +1459,44 @@ export function ChatComposer({ mirrorToMaster: true, }); setMessageTone("error"); - setMessage("消息发送失败,请重试。"); + setMessage(typeof result.message === "string" ? result.message : "消息发送失败,请重试。"); + } + + async function handleThreadExecutionConflictDecision( + decision: ThreadConversationExecutionConflictAction, + ) { + if (!threadExecutionConflict) { + return; + } + setLoading(true); + const response = await fetch(`/api/v1/devices/${threadExecutionConflict.conflict.deviceId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + projectId: threadExecutionConflict.conflict.projectId, + folderKey: threadExecutionConflict.conflict.folderKey ?? null, + conflictDecision: decision, + }), + }); + const result = (await response.json()) as { + ok: boolean; + message?: string; + }; + setLoading(false); + if (!result.ok) { + setMessageTone("error"); + setMessage(result.message ?? "冲突放行设置失败,请重试。"); + return; + } + + const pendingDraft = threadExecutionConflict; + setThreadExecutionConflict(null); + setMessageTone(decision === "forbid" ? "error" : "success"); + setMessage(summarizeThreadConversationExecutionDecisionResult(decision)); + if (decision === "forbid") { + return; + } + await send(pendingDraft.kind, { draftBody: pendingDraft.draftBody }); } return ( @@ -1477,6 +1554,40 @@ export function ChatComposer({ {dispatchPlanRecoveryHint} ) : null} + {threadExecutionConflict && threadExecutionConflictDescription ? ( +
+
+ {threadExecutionConflictDescription.title} +
+
{threadExecutionConflictDescription.summary}
+
+ 设备:{threadExecutionConflict.conflict.deviceName} + {" · "} + 默认模式:{threadExecutionConflict.conflict.preferredExecutionMode === "gui" ? "GUI" : "CLI"} + {threadExecutionConflict.conflict.folderKey ? ` · ${threadExecutionConflict.conflict.folderKey}` : ""} +
+
+ {threadExecutionConflict.conflict.actions.map((action) => ( + + ))} +
+
+ ) : null} {pendingDispatchPlan ? (
diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index a1ca264..4ff1f82 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -25,9 +25,12 @@ import type { AiProvider, DispatchPlanTarget, Project, + ProjectExecutionPolicy, ProjectAgentControls, ReasoningEffort, } from "@/lib/boss-data"; +import type { ThreadConversationExecutionConflict } from "@/lib/thread-execution-conflict"; +import { THREAD_CONVERSATION_EXECUTION_CONFLICT_ACTIONS } from "@/lib/thread-execution-conflict"; import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments"; import { CLAW_BACKEND_ID, @@ -91,6 +94,16 @@ type QueuedMasterAgentReplyEnvelope = { }; }; +export class ThreadConversationExecutionConflictError extends Error { + conflict: ThreadConversationExecutionConflict; + + constructor(conflict: ThreadConversationExecutionConflict) { + super("THREAD_EXECUTION_CONFLICT"); + this.name = "ThreadConversationExecutionConflictError"; + this.conflict = conflict; + } +} + export async function resolveMasterAgentExecutionConfig( projectId: string, accountId?: string, @@ -226,6 +239,116 @@ function buildThreadConversationReplyPrompt(project: Project, requestText: strin ].join("\n"); } +function buildThreadConversationFolderKey(project: Project) { + const deviceId = project.deviceIds[0]; + const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase(); + if (!deviceId || !folderRef) { + return undefined; + } + return `${deviceId}:${folderRef}`; +} + +function findThreadConflictPolicy( + policies: ProjectExecutionPolicy[], + input: { + deviceId: string; + projectId: string; + folderKey?: string; + }, +) { + if (input.folderKey) { + const folderMatch = policies.find( + (policy) => policy.deviceId === input.deviceId && policy.folderKey === input.folderKey, + ); + if (folderMatch) { + return folderMatch; + } + } + return policies.find( + (policy) => policy.deviceId === input.deviceId && policy.projectId === input.projectId, + ); +} + +async function resolveThreadConversationExecutionContext(projectId: string) { + const state = await readState(); + const project = state.projects.find((item) => item.id === projectId); + if (!project) { + throw new Error("PROJECT_NOT_FOUND"); + } + if (project.isGroup) { + throw new Error("PROJECT_NOT_SINGLE_THREAD"); + } + if (project.id === "master-agent") { + throw new Error("PROJECT_NOT_THREAD_CONVERSATION"); + } + if (!project.threadMeta.codexThreadRef?.trim()) { + throw new Error("THREAD_BINDING_REQUIRED"); + } + + const deviceId = project.deviceIds[0] || state.user.boundDeviceId || "mac-studio"; + const device = state.devices.find((item) => item.id === deviceId); + if (!device || device.status !== "online") { + throw new Error("THREAD_TARGET_DEVICE_OFFLINE"); + } + + const folderKey = buildThreadConversationFolderKey(project); + const matchingPolicy = findThreadConflictPolicy(state.projectExecutionPolicies, { + deviceId, + projectId: project.id, + folderKey, + }); + + return { + project, + device, + deviceId, + folderKey, + matchingPolicy, + }; +} + +export async function getThreadConversationExecutionConflict(projectId: string) { + const context = await resolveThreadConversationExecutionContext(projectId); + const { project, device, deviceId, folderKey, matchingPolicy } = context; + const preferredExecutionMode = device.preferredExecutionMode ?? "cli"; + + if (matchingPolicy?.allowPolicy === "allow_once" || matchingPolicy?.allowPolicy === "allow_always") { + return null; + } + + if (preferredExecutionMode === "gui") { + return { + projectId: project.id, + projectName: project.name, + deviceId, + deviceName: device.name, + folderKey, + preferredExecutionMode, + allowPolicy: matchingPolicy?.allowPolicy ?? "forbid", + conflictState: matchingPolicy?.conflictState ?? "blocked", + reason: "preferred_gui_mode" as const, + actions: [...THREAD_CONVERSATION_EXECUTION_CONFLICT_ACTIONS], + }; + } + + if (matchingPolicy?.conflictState === "blocked" && matchingPolicy.allowPolicy === "forbid") { + return { + projectId: project.id, + projectName: project.name, + deviceId, + deviceName: device.name, + folderKey, + preferredExecutionMode, + allowPolicy: matchingPolicy.allowPolicy, + conflictState: matchingPolicy.conflictState, + reason: "project_conflict_forbid" as const, + actions: [...THREAD_CONVERSATION_EXECUTION_CONFLICT_ACTIONS], + }; + } + + return null; +} + function buildRuntimeDigest( state: Awaited>, requestText: string, @@ -1701,26 +1824,11 @@ export async function queueThreadConversationReplyTask(params: { requestedBy: string; requestedByAccount: string; }) { - const state = await readState(); - const project = state.projects.find((item) => item.id === params.projectId); - if (!project) { - throw new Error("PROJECT_NOT_FOUND"); - } - if (project.isGroup) { - throw new Error("PROJECT_NOT_SINGLE_THREAD"); - } - if (project.id === "master-agent") { - throw new Error("PROJECT_NOT_THREAD_CONVERSATION"); - } - if (!project.threadMeta.codexThreadRef?.trim()) { - throw new Error("THREAD_BINDING_REQUIRED"); - } - - const deviceId = project.deviceIds[0] || state.user.boundDeviceId || "mac-studio"; - const device = state.devices.find((item) => item.id === deviceId); - if (!device || device.status !== "online") { - throw new Error("THREAD_TARGET_DEVICE_OFFLINE"); + const conflict = await getThreadConversationExecutionConflict(params.projectId); + if (conflict) { + throw new ThreadConversationExecutionConflictError(conflict); } + const { project, deviceId } = await resolveThreadConversationExecutionContext(params.projectId); return queueMasterAgentTask({ projectId: project.id, taskType: "conversation_reply", diff --git a/src/lib/thread-execution-conflict-ui.ts b/src/lib/thread-execution-conflict-ui.ts new file mode 100644 index 0000000..70a882d --- /dev/null +++ b/src/lib/thread-execution-conflict-ui.ts @@ -0,0 +1,48 @@ +import type { + ThreadConversationExecutionConflict, + ThreadConversationExecutionConflictAction, +} from "@/lib/thread-execution-conflict"; + +export function describeThreadConversationExecutionConflict( + conflict: ThreadConversationExecutionConflict, +) { + if (conflict.reason === "preferred_gui_mode") { + return { + title: "当前项目默认先走 GUI", + summary: `${conflict.deviceName} 现在默认优先 GUI。要让主 Agent 继续通过 CLI 推进 ${conflict.projectName},需要你先对这个项目放行;这个选择只对这个项目生效。`, + }; + } + + return { + title: "当前项目已命中并发保护", + summary: `${conflict.projectName} 最近检测到 GUI / CLI 同时活动,当前先按禁止处理。这个提示只影响这个项目;你可以临时放行,或者把这个项目永久放行。`, + }; +} + +export function labelForThreadConversationExecutionConflictDecision( + decision: ThreadConversationExecutionConflictAction, +) { + switch (decision) { + case "allow_once": + return "允许本次"; + case "allow_always": + return "永久放行"; + case "forbid": + default: + return "禁止"; + } +} + +export function summarizeThreadConversationExecutionDecisionResult( + decision: ThreadConversationExecutionConflictAction, +) { + switch (decision) { + case "allow_once": + return "已允许本次,继续发送中…"; + case "allow_always": + return "已对当前项目永久放行,继续发送中…"; + case "forbid": + default: + return "已保持禁止,这次消息没有发出。"; + } +} diff --git a/src/lib/thread-execution-conflict.ts b/src/lib/thread-execution-conflict.ts new file mode 100644 index 0000000..6d444e2 --- /dev/null +++ b/src/lib/thread-execution-conflict.ts @@ -0,0 +1,25 @@ +export type ThreadConversationExecutionConflictAction = "forbid" | "allow_once" | "allow_always"; +export type ThreadConversationExecutionConflictState = "none" | "warning" | "blocked"; +export type ThreadConversationExecutionPreferredMode = "gui" | "cli"; +export type ThreadConversationExecutionConflictReason = + | "preferred_gui_mode" + | "project_conflict_forbid"; + +export interface ThreadConversationExecutionConflict { + projectId: string; + projectName: string; + deviceId: string; + deviceName: string; + folderKey?: string; + preferredExecutionMode: ThreadConversationExecutionPreferredMode; + allowPolicy: ThreadConversationExecutionConflictAction; + conflictState: ThreadConversationExecutionConflictState; + reason: ThreadConversationExecutionConflictReason; + actions: ThreadConversationExecutionConflictAction[]; +} + +export const THREAD_CONVERSATION_EXECUTION_CONFLICT_ACTIONS: ThreadConversationExecutionConflictAction[] = [ + "forbid", + "allow_once", + "allow_always", +]; diff --git a/tests/single-thread-message-execution.test.ts b/tests/single-thread-message-execution.test.ts index aa6069d..02bbd57 100644 --- a/tests/single-thread-message-execution.test.ts +++ b/tests/single-thread-message-execution.test.ts @@ -100,6 +100,11 @@ function buildSingleThreadProject(projectId: string) { }; } +function buildProjectFolderKey(project: ReturnType) { + const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase(); + return `${project.deviceIds[0]}:${folderRef}`; +} + async function ensureSingleThreadProject() { const state = await readState(); const existing = findSingleThreadProject(state); @@ -159,6 +164,131 @@ test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task fo assert.ok(!task?.executionPrompt?.includes("deviceIds:"), "thread prompt should not include device id labels"); }); +test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when the target device prefers gui mode", async () => { + await setup(); + const singleProject = await ensureSingleThreadProject(); + assert.ok(singleProject, "expected a seeded single-thread project"); + + const state = await readState(); + const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]); + assert.ok(targetDevice, "expected a seeded target device"); + targetDevice.preferredExecutionMode = "gui"; + await writeState(state); + + const response = await postMessageRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`, + "POST", + { body: "继续推进当前线程" }, + ), + { params: Promise.resolve({ projectId: singleProject.id }) }, + ); + assert.equal(response.status, 409); + + const payload = (await response.json()) as { + ok: boolean; + code?: string; + message?: string; + executionConflict?: { + projectId: string; + deviceId: string; + preferredExecutionMode: "gui" | "cli"; + allowPolicy: "forbid" | "allow_once" | "allow_always"; + conflictState: "none" | "warning" | "blocked"; + reason: string; + actions: string[]; + }; + }; + + assert.equal(payload.ok, false); + assert.equal(payload.code, "THREAD_EXECUTION_CONFLICT"); + assert.equal(payload.executionConflict?.projectId, singleProject.id); + assert.equal(payload.executionConflict?.deviceId, singleProject.deviceIds[0]); + assert.equal(payload.executionConflict?.preferredExecutionMode, "gui"); + assert.equal(payload.executionConflict?.allowPolicy, "forbid"); + assert.equal(payload.executionConflict?.conflictState, "blocked"); + assert.equal(payload.executionConflict?.reason, "preferred_gui_mode"); + assert.deepEqual(payload.executionConflict?.actions, ["forbid", "allow_once", "allow_always"]); + + const nextState = await readState(); + const updatedProject = nextState.projects.find((project) => project.id === singleProject.id); + const blockedMessage = updatedProject?.messages.find((message) => message.body.includes("继续推进当前线程")); + assert.equal(blockedMessage, undefined, "blocked send should not append a local chat message"); + const queuedTask = nextState.masterAgentTasks.find( + (item) => + item.taskType === "conversation_reply" && + item.projectId === singleProject.id && + item.requestText === "继续推进当前线程", + ); + assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task"); +}); + +test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when the current project folder is forbidden", async () => { + await setup(); + const singleProject = await ensureSingleThreadProject(); + assert.ok(singleProject, "expected a seeded single-thread project"); + + const state = await readState(); + const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]); + assert.ok(targetDevice, "expected a seeded target device"); + targetDevice.preferredExecutionMode = "cli"; + state.projectExecutionPolicies = [ + { + deviceId: singleProject.deviceIds[0], + folderKey: buildProjectFolderKey(singleProject), + projectId: singleProject.id, + allowPolicy: "forbid", + conflictState: "blocked", + updatedAt: "2026-04-06T13:20:00.000Z", + }, + ]; + await writeState(state); + + const response = await postMessageRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`, + "POST", + { body: "继续同步项目进度" }, + ), + { params: Promise.resolve({ projectId: singleProject.id }) }, + ); + assert.equal(response.status, 409); + + const payload = (await response.json()) as { + ok: boolean; + code?: string; + executionConflict?: { + projectId: string; + folderKey?: string; + preferredExecutionMode: "gui" | "cli"; + allowPolicy: "forbid" | "allow_once" | "allow_always"; + conflictState: "none" | "warning" | "blocked"; + reason: string; + }; + }; + + assert.equal(payload.ok, false); + assert.equal(payload.code, "THREAD_EXECUTION_CONFLICT"); + assert.equal(payload.executionConflict?.projectId, singleProject.id); + assert.equal(payload.executionConflict?.folderKey, buildProjectFolderKey(singleProject)); + assert.equal(payload.executionConflict?.preferredExecutionMode, "cli"); + assert.equal(payload.executionConflict?.allowPolicy, "forbid"); + assert.equal(payload.executionConflict?.conflictState, "blocked"); + assert.equal(payload.executionConflict?.reason, "project_conflict_forbid"); + + const nextState = await readState(); + const updatedProject = nextState.projects.find((project) => project.id === singleProject.id); + const blockedMessage = updatedProject?.messages.find((message) => message.body.includes("继续同步项目进度")); + assert.equal(blockedMessage, undefined, "blocked send should not append a local chat message"); + const queuedTask = nextState.masterAgentTasks.find( + (item) => + item.taskType === "conversation_reply" && + item.projectId === singleProject.id && + item.requestText === "继续同步项目进度", + ); + assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task"); +}); + test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread reply back to the single-thread project", async () => { await setup(); const singleProject = await ensureSingleThreadProject(); diff --git a/tests/thread-execution-conflict-ui.test.ts b/tests/thread-execution-conflict-ui.test.ts new file mode 100644 index 0000000..d415561 --- /dev/null +++ b/tests/thread-execution-conflict-ui.test.ts @@ -0,0 +1,51 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + describeThreadConversationExecutionConflict, + labelForThreadConversationExecutionConflictDecision, +} from "../src/lib/thread-execution-conflict-ui.ts"; + +test("describeThreadConversationExecutionConflict explains preferred gui mode with project-scoped guidance", () => { + const description = describeThreadConversationExecutionConflict({ + projectId: "thread-ui", + projectName: "Boss UI 主线程", + deviceId: "mac-studio", + deviceName: "Mac Studio", + folderKey: "mac-studio:boss", + preferredExecutionMode: "gui", + allowPolicy: "forbid", + conflictState: "blocked", + reason: "preferred_gui_mode", + actions: ["forbid", "allow_once", "allow_always"], + }); + + assert.equal(description.title, "当前项目默认先走 GUI"); + assert.match(description.summary, /Mac Studio/); + assert.match(description.summary, /只对这个项目/); + assert.match(description.summary, /Boss UI 主线程/); +}); + +test("describeThreadConversationExecutionConflict explains project-level forbid without implying a global lock", () => { + const description = describeThreadConversationExecutionConflict({ + projectId: "thread-ui", + projectName: "Boss UI 主线程", + deviceId: "mac-studio", + deviceName: "Mac Studio", + folderKey: "mac-studio:boss", + preferredExecutionMode: "cli", + allowPolicy: "forbid", + conflictState: "blocked", + reason: "project_conflict_forbid", + actions: ["forbid", "allow_once", "allow_always"], + }); + + assert.equal(description.title, "当前项目已命中并发保护"); + assert.match(description.summary, /最近检测到 GUI \/ CLI 同时活动/); + assert.match(description.summary, /只影响这个项目/); +}); + +test("labelForThreadConversationExecutionConflictDecision keeps the three project-scoped actions concise", () => { + assert.equal(labelForThreadConversationExecutionConflictDecision("forbid"), "禁止"); + assert.equal(labelForThreadConversationExecutionConflictDecision("allow_once"), "允许本次"); + assert.equal(labelForThreadConversationExecutionConflictDecision("allow_always"), "永久放行"); +});