diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index 140411b..a776cba 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -133,6 +133,28 @@ public class BossApiClient { return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload); } + public ApiResponse updateProjectTakeoverSettings( + String projectId, + @Nullable Boolean takeoverEnabled, + @Nullable Boolean globalTakeoverEnabled + ) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + if (!"master-agent".equals(projectId)) { + if (takeoverEnabled == null) { + payload.put("takeoverEnabled", JSONObject.NULL); + } else { + payload.put("takeoverEnabled", takeoverEnabled); + } + } + if (globalTakeoverEnabled != null || "master-agent".equals(projectId)) { + payload.put( + "globalTakeoverEnabled", + globalTakeoverEnabled == null ? JSONObject.NULL : globalTakeoverEnabled + ); + } + return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload); + } + public ApiResponse getProjectOrchestrationBackend(String projectId) throws IOException, JSONException { return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/orchestration-backend", null); } diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java index 79a1276..cebc751 100644 --- a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java @@ -7,6 +7,7 @@ import android.widget.LinearLayout; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.SwitchCompat; import org.json.JSONArray; import org.json.JSONObject; @@ -19,6 +20,8 @@ public class ConversationInfoActivity extends BossScreenActivity { private String projectName; private String projectFolderName; private int participantCount; + private boolean takeoverEnabled; + private boolean takeoverInheritedFromGlobal; @Override protected int getLayoutResId() { @@ -82,9 +85,12 @@ public class ConversationInfoActivity extends BossScreenActivity { } projectName = project.optString("name", projectName == null ? "会话信息" : projectName); + JSONObject agentControls = detail.optJSONObject("agentControls"); JSONObject threadMeta = project.optJSONObject("threadMeta"); projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); participantCount = participants == null ? 0 : participants.length(); + takeoverEnabled = agentControls != null && agentControls.optBoolean("effectiveTakeoverEnabled", false); + takeoverInheritedFromGlobal = agentControls != null && agentControls.optBoolean("takeoverInheritedFromGlobal", false); configureScreen("会话信息", buildSubtitle(threadMeta, participantCount)); appendContent(BossUi.buildSimpleProfileHeader( @@ -95,6 +101,7 @@ public class ConversationInfoActivity extends BossScreenActivity { )); appendThreadStatusSummary(threadStatusPayload); + appendTakeoverControl(); appendContent(BossUi.buildWechatMenuRow( this, @@ -152,6 +159,21 @@ public class ConversationInfoActivity extends BossScreenActivity { setRefreshing(false); } + private void appendTakeoverControl() { + SwitchCompat takeoverSwitch = new SwitchCompat(this); + takeoverSwitch.setText("开启"); + takeoverSwitch.setChecked(takeoverEnabled); + takeoverSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> saveTakeoverSetting(isChecked)); + appendContent(BossUi.buildFormCell( + this, + "主 Agent 协同接管", + takeoverInheritedFromGlobal + ? "当前跟随全局默认开启。主 Agent 会协同推进,但不会抢走你直接控制线程开发的能力。" + : "为这个线程单独开启主 Agent 协同推进。不会抢走你直接控制线程开发的能力。", + takeoverSwitch + )); + } + private void appendThreadStatusSummary(@Nullable JSONObject threadStatusPayload) { if (threadStatusPayload == null) { return; @@ -325,6 +347,32 @@ public class ConversationInfoActivity extends BossScreenActivity { }); } + private void saveTakeoverSetting(boolean enabled) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.updateProjectTakeoverSettings( + projectId, + enabled, + null + ); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + runOnUiThread(() -> { + showMessage(enabled ? "已开启主 Agent 协同接管" : "已关闭主 Agent 协同接管"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("保存失败:" + error.getMessage()); + reload(); + }); + } + }); + } + private String buildSubtitle(@Nullable JSONObject threadMeta, int count) { String folder = threadMeta == null ? "" : threadMeta.optString("folderName", ""); String suffix = count <= 0 ? "暂无参与线程" : count + " 个参与线程"; diff --git a/android/app/src/main/java/com/hyzq/boss/MasterAgentPromptActivity.java b/android/app/src/main/java/com/hyzq/boss/MasterAgentPromptActivity.java index b4a1d79..30a4c7f 100644 --- a/android/app/src/main/java/com/hyzq/boss/MasterAgentPromptActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MasterAgentPromptActivity.java @@ -12,6 +12,7 @@ import android.widget.Spinner; import android.widget.TextView; import androidx.annotation.Nullable; +import androidx.appcompat.widget.SwitchCompat; import org.json.JSONObject; @@ -32,12 +33,14 @@ public class MasterAgentPromptActivity extends BossScreenActivity { private @Nullable String userPromptText; private @Nullable String projectPromptOverrideText; private @Nullable String backendOverrideText; + private boolean globalTakeoverEnabled; private boolean clawSelectable; private @Nullable String clawReasonLabel; private final List backendOverrideValues = new ArrayList<>(); private EditText userPromptInput; private EditText projectPromptInput; private Spinner backendSpinner; + private SwitchCompat globalTakeoverSwitch; private TextView previewTextView; @Override @@ -91,6 +94,7 @@ public class MasterAgentPromptActivity extends BossScreenActivity { projectControls == null ? "" : projectControls.optString("promptOverride", "") ); backendOverrideText = projectControls == null ? "" : projectControls.optString("backendOverride", ""); + globalTakeoverEnabled = projectControls != null && projectControls.optBoolean("globalTakeoverEnabled", false); clawSelectable = clawAvailability != null && clawAvailability.optBoolean("selectable", false); clawReasonLabel = clawAvailability == null ? "" : clawAvailability.optString("reasonLabel", ""); @@ -159,9 +163,20 @@ public class MasterAgentPromptActivity extends BossScreenActivity { "默认沿用 Boss 当前主链;需要时可显式切到 Claw Runtime。", backendSpinner )); + + globalTakeoverSwitch = new SwitchCompat(this); + globalTakeoverSwitch.setText("开启"); + globalTakeoverSwitch.setChecked(globalTakeoverEnabled); + globalTakeoverSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> refreshPreview()); + appendContent(BossUi.buildFormCell( + this, + "全局主 Agent 协同接管", + "为线程会话默认开启主 Agent 协同推进。不会抢走你直接控制线程开发的能力。", + globalTakeoverSwitch + )); if (!clawSelectable) { appendContent(BossUi.buildSoftPanel( - this, + this, "Claw Runtime 当前不可用", TextUtils.isEmpty(clawReasonLabel) ? "当前环境未满足 Claw Runtime 的启动条件。" : clawReasonLabel, TextUtils.equals(backendOverrideText, "claw-runtime") @@ -235,6 +250,12 @@ public class MasterAgentPromptActivity extends BossScreenActivity { } else if (TextUtils.equals(backendOverrideText, "claw-runtime") && !clawSelectable) { builder.append("【执行后端】\n默认(Claw Runtime 当前不可用,运行时会自动回退)\n\n"); } + boolean globalTakeover = globalTakeoverSwitch != null + ? globalTakeoverSwitch.isChecked() + : globalTakeoverEnabled; + builder.append("【全局主 Agent 协同接管】\n") + .append(globalTakeover ? "已开启" : "已关闭") + .append("(不会抢走你直接控制线程开发)\n\n"); if (builder.length() == 0) { return "当前没有任何提示词内容。"; } @@ -251,6 +272,7 @@ public class MasterAgentPromptActivity extends BossScreenActivity { final String backendOverride = backendSpinner == null ? "" : backendOverrideValues.get(backendSpinner.getSelectedItemPosition()); + final boolean globalTakeover = globalTakeoverSwitch != null && globalTakeoverSwitch.isChecked(); setRefreshing(true); executor.execute(() -> { try { @@ -262,6 +284,14 @@ public class MasterAgentPromptActivity extends BossScreenActivity { if (!response.ok()) { throw new IllegalStateException(response.message()); } + BossApiClient.ApiResponse controlsResponse = apiClient.updateProjectTakeoverSettings( + projectId, + null, + globalTakeover + ); + if (!controlsResponse.ok()) { + throw new IllegalStateException(controlsResponse.message()); + } runOnUiThread(() -> { showMessage("提示词已保存"); setResult(RESULT_OK); diff --git a/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java index 08a0573..a1c0b0d 100644 --- a/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java @@ -53,10 +53,11 @@ public class ConversationInfoActivityTest { assertTrue(viewTreeContainsText(content.getChildAt(1), "线程状态摘要")); assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "当前进度:已经记录最近 2 条进展")); assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "建议下一步:继续同步 Android 只读页")); - assertTrue(viewTreeContainsText(content.getChildAt(2), "发起群聊")); - assertTrue(viewTreeContainsText(content.getChildAt(2), "选择其他线程加入新群")); - assertTrue(viewTreeContainsText(content.getChildAt(3), "线程详情")); - assertTrue(viewTreeContainsText(content.getChildAt(3), "查看当前线程聊天与项目")); + assertTrue(viewTreeContainsText(content.getChildAt(2), "主 Agent 协同接管")); + assertTrue(viewTreeContainsText(content.getChildAt(3), "发起群聊")); + assertTrue(viewTreeContainsText(content.getChildAt(3), "选择其他线程加入新群")); + assertTrue(viewTreeContainsText(content.getChildAt(4), "线程详情")); + assertTrue(viewTreeContainsText(content.getChildAt(4), "查看当前线程聊天与项目")); assertTrue(viewTreeContainsText(content, "参与线程")); assertTrue(viewTreeContainsText(content, "硬件审计协作")); assertFalse(viewTreeContainsText(content, "从当前会话选择其他线程,创建新的独立群聊")); @@ -173,7 +174,11 @@ public class ConversationInfoActivityTest { .put("isGroup", false) .put("deviceIds", new JSONArray().put("mac-studio").put("macbook")) .put("threadMeta", threadMeta); - return new JSONObject().put("project", project); + return new JSONObject() + .put("project", project) + .put("agentControls", new JSONObject() + .put("effectiveTakeoverEnabled", true) + .put("takeoverInheritedFromGlobal", true)); } private static JSONObject buildParticipantsPayload() throws Exception { diff --git a/android/app/src/test/java/com/hyzq/boss/MasterAgentPromptActivityTest.java b/android/app/src/test/java/com/hyzq/boss/MasterAgentPromptActivityTest.java index 06ba150..1837e17 100644 --- a/android/app/src/test/java/com/hyzq/boss/MasterAgentPromptActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MasterAgentPromptActivityTest.java @@ -54,7 +54,8 @@ public class MasterAgentPromptActivityTest { .put("userPrompt", new JSONObject().put("content", "用户私有主提示词")) .put("projectControls", new JSONObject() .put("promptOverride", "当前对话提示词") - .put("backendOverride", "claw-runtime")); + .put("backendOverride", "claw-runtime") + .put("globalTakeoverEnabled", true)); ReflectionHelpers.callInstanceMethod( activity, @@ -68,6 +69,7 @@ public class MasterAgentPromptActivityTest { assertTrue(viewTreeContainsText(content, "用户私有主提示词")); assertTrue(viewTreeContainsText(content, "当前对话提示词")); assertTrue(viewTreeContainsText(content, "执行后端")); + assertTrue(viewTreeContainsText(content, "全局主 Agent 协同接管")); assertTrue(viewTreeContainsText(content, "合成预览")); } @@ -283,6 +285,18 @@ public class MasterAgentPromptActivityTest { void rememberIdentity(JSONObject json) { // JVM 单测不需要落 Android 侧身份缓存。 } + + @Override + public ApiResponse updateProjectTakeoverSettings(String projectId, Boolean takeoverEnabled, Boolean globalTakeoverEnabled) { + try { + return new ApiResponse( + 200, + new JSONObject().put("ok", true) + ); + } catch (Exception error) { + throw new IllegalStateException(error); + } + } } private static final class InMemorySharedPreferences implements android.content.SharedPreferences { diff --git a/src/app/api/v1/projects/[projectId]/agent-controls/route.ts b/src/app/api/v1/projects/[projectId]/agent-controls/route.ts index 7110ef8..d06f3e5 100644 --- a/src/app/api/v1/projects/[projectId]/agent-controls/route.ts +++ b/src/app/api/v1/projects/[projectId]/agent-controls/route.ts @@ -14,10 +14,6 @@ export async function GET( context: { params: Promise<{ projectId: string }> }, ) { const { projectId } = await context.params; - if (projectId !== "master-agent") { - return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); - } - const projectExists = await hasPersistedProject(projectId); if (!projectExists) { return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); @@ -45,9 +41,6 @@ export async function POST( } const { projectId } = await context.params; - if (projectId !== "master-agent") { - return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); - } const rawBody = await request.text().catch(() => ""); let body: unknown; try { @@ -64,6 +57,8 @@ export async function POST( reasoningEffortOverride?: unknown; promptOverride?: unknown; backendOverride?: unknown; + takeoverEnabled?: unknown; + globalTakeoverEnabled?: unknown; }; const hasModelOverride = Object.prototype.hasOwnProperty.call(payload, "modelOverride"); const hasReasoningEffortOverride = Object.prototype.hasOwnProperty.call( @@ -72,9 +67,30 @@ export async function POST( ); const hasPromptOverride = Object.prototype.hasOwnProperty.call(payload, "promptOverride"); const hasBackendOverride = Object.prototype.hasOwnProperty.call(payload, "backendOverride"); - const allowedKeys = new Set(["modelOverride", "reasoningEffortOverride", "promptOverride", "backendOverride"]); + const hasTakeoverEnabled = Object.prototype.hasOwnProperty.call(payload, "takeoverEnabled"); + const hasGlobalTakeoverEnabled = Object.prototype.hasOwnProperty.call(payload, "globalTakeoverEnabled"); + const allowedKeys = + projectId === "master-agent" + ? new Set([ + "modelOverride", + "reasoningEffortOverride", + "promptOverride", + "backendOverride", + "globalTakeoverEnabled", + ]) + : new Set(["takeoverEnabled"]); const hasUnsupportedKeys = Object.keys(payload).some((key) => !allowedKeys.has(key)); - if ((!hasModelOverride && !hasReasoningEffortOverride && !hasPromptOverride && !hasBackendOverride) || hasUnsupportedKeys) { + if ( + ( + !hasModelOverride && + !hasReasoningEffortOverride && + !hasPromptOverride && + !hasBackendOverride && + !hasTakeoverEnabled && + !hasGlobalTakeoverEnabled + ) || + hasUnsupportedKeys + ) { return NextResponse.json({ ok: false, message: "INVALID_AGENT_CONTROLS_PAYLOAD" }, { status: 400 }); } @@ -104,6 +120,22 @@ export async function POST( ) { return NextResponse.json({ ok: false, message: "INVALID_BACKEND_OVERRIDE" }, { status: 400 }); } + if ( + hasTakeoverEnabled && + payload.takeoverEnabled !== undefined && + payload.takeoverEnabled !== null && + typeof payload.takeoverEnabled !== "boolean" + ) { + return NextResponse.json({ ok: false, message: "INVALID_TAKEOVER_ENABLED" }, { status: 400 }); + } + if ( + hasGlobalTakeoverEnabled && + payload.globalTakeoverEnabled !== undefined && + payload.globalTakeoverEnabled !== null && + typeof payload.globalTakeoverEnabled !== "boolean" + ) { + return NextResponse.json({ ok: false, message: "INVALID_GLOBAL_TAKEOVER_ENABLED" }, { status: 400 }); + } try { if (hasBackendOverride && payload.backendOverride === "claw-runtime") { @@ -123,6 +155,8 @@ export async function POST( ...(hasReasoningEffortOverride ? { reasoningEffortOverride: payload.reasoningEffortOverride } : {}), ...(hasPromptOverride ? { promptOverride: payload.promptOverride } : {}), ...(hasBackendOverride ? { backendOverride: payload.backendOverride } : {}), + ...(hasTakeoverEnabled ? { takeoverEnabled: payload.takeoverEnabled } : {}), + ...(hasGlobalTakeoverEnabled ? { globalTakeoverEnabled: payload.globalTakeoverEnabled } : {}), }, session.account, ); diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 840970b..8e42e96 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -402,6 +402,10 @@ export interface ProjectAgentControls { reasoningEffortOverride?: ReasoningEffort; promptOverride?: string; backendOverride?: "claw-runtime"; + takeoverEnabled?: boolean; + globalTakeoverEnabled?: boolean; + effectiveTakeoverEnabled?: boolean; + takeoverInheritedFromGlobal?: boolean; updatedAt: string; } @@ -1671,6 +1675,16 @@ function parseBackendOverride(value: unknown) { return { kind: "set" as const, value: "claw-runtime" as const }; } +function parseBooleanControlOverride(value: unknown) { + if (value === undefined || value === null) { + return { kind: "clear" as const }; + } + if (typeof value !== "boolean") { + return { kind: "invalid" as const }; + } + return { kind: "set" as const, value }; +} + function normalizeOrchestrationBackendOverride( value: unknown, ): OrchestrationBackendOverride | undefined { @@ -2129,8 +2143,18 @@ function normalizeProjectAgentControls( : undefined; const promptOverride = trimToDefined(raw?.promptOverride); const backendOverride = raw?.backendOverride === "claw-runtime" ? raw.backendOverride : undefined; + const takeoverEnabled = typeof raw?.takeoverEnabled === "boolean" ? raw.takeoverEnabled : undefined; + const globalTakeoverEnabled = + typeof raw?.globalTakeoverEnabled === "boolean" ? raw.globalTakeoverEnabled : undefined; - if (!modelOverride && !reasoningEffortOverride && !promptOverride && !backendOverride) { + if ( + !modelOverride && + !reasoningEffortOverride && + !promptOverride && + !backendOverride && + takeoverEnabled === undefined && + globalTakeoverEnabled === undefined + ) { return undefined; } @@ -2139,6 +2163,8 @@ function normalizeProjectAgentControls( reasoningEffortOverride, promptOverride, backendOverride, + takeoverEnabled, + globalTakeoverEnabled, updatedAt: raw?.updatedAt ?? nowIso(), }; } @@ -3939,11 +3965,11 @@ function findUserProjectAgentControls( ); } -export async function getProjectAgentControls(projectId: string, account?: string) { - if (projectId !== "master-agent") { - return null; - } - const state = await readState(); +function resolveStoredProjectAgentControls( + state: BossState, + projectId: string, + account?: string, +) { const scopedControls = findUserProjectAgentControls(state, projectId, account); if (scopedControls?.controls) { return scopedControls.controls; @@ -3951,6 +3977,44 @@ export async function getProjectAgentControls(projectId: string, account?: strin return state.projects.find((project) => project.id === projectId)?.agentControls ?? null; } +function applyDerivedTakeoverControls( + state: BossState, + projectId: string, + account: string | undefined, + controls: ProjectAgentControls | null, +): ProjectAgentControls | null { + const normalized = controls ? { ...controls } : null; + if (projectId === "master-agent") { + return normalized; + } + const globalControls = resolveStoredProjectAgentControls(state, "master-agent", account); + const explicitTakeover = normalized?.takeoverEnabled; + const inheritedGlobalTakeover = globalControls?.globalTakeoverEnabled; + const effectiveTakeoverEnabled = + explicitTakeover !== undefined ? explicitTakeover : Boolean(inheritedGlobalTakeover); + const takeoverInheritedFromGlobal = + explicitTakeover === undefined && inheritedGlobalTakeover !== undefined; + if (!normalized && !takeoverInheritedFromGlobal && !effectiveTakeoverEnabled) { + return null; + } + return { + ...(normalized ?? { updatedAt: globalControls?.updatedAt ?? nowIso() }), + updatedAt: normalized?.updatedAt ?? globalControls?.updatedAt ?? nowIso(), + effectiveTakeoverEnabled, + takeoverInheritedFromGlobal, + }; +} + +export async function getProjectAgentControls(projectId: string, account?: string) { + const projectExists = await hasPersistedProject(projectId); + if (!projectExists) { + return null; + } + const state = await readState(); + const controls = resolveStoredProjectAgentControls(state, projectId, account); + return applyDerivedTakeoverControls(state, projectId, account, controls); +} + export async function updateProjectAgentControls( projectId: string, payload: { @@ -3958,11 +4022,14 @@ export async function updateProjectAgentControls( reasoningEffortOverride?: unknown; promptOverride?: unknown; backendOverride?: unknown; + takeoverEnabled?: unknown; + globalTakeoverEnabled?: unknown; }, account?: string, ) { - if (projectId !== "master-agent") { - throw new Error("MASTER_AGENT_CONTROLS_SCOPE_RESTRICTED"); + const projectExists = await hasPersistedProject(projectId); + if (!projectExists) { + throw new Error("PROJECT_NOT_FOUND"); } const modelOverrideInput = Object.prototype.hasOwnProperty.call(payload, "modelOverride") @@ -3977,6 +4044,12 @@ export async function updateProjectAgentControls( const backendOverrideInput = Object.prototype.hasOwnProperty.call(payload, "backendOverride") ? parseBackendOverride(payload.backendOverride) : { kind: "preserve" as const }; + const takeoverEnabledInput = Object.prototype.hasOwnProperty.call(payload, "takeoverEnabled") + ? parseBooleanControlOverride(payload.takeoverEnabled) + : { kind: "preserve" as const }; + const globalTakeoverEnabledInput = Object.prototype.hasOwnProperty.call(payload, "globalTakeoverEnabled") + ? parseBooleanControlOverride(payload.globalTakeoverEnabled) + : { kind: "preserve" as const }; if (modelOverrideInput.kind === "invalid") { throw new Error("INVALID_MODEL_OVERRIDE"); } @@ -3989,6 +4062,25 @@ export async function updateProjectAgentControls( if (backendOverrideInput.kind === "invalid") { throw new Error("INVALID_BACKEND_OVERRIDE"); } + if (takeoverEnabledInput.kind === "invalid") { + throw new Error("INVALID_TAKEOVER_ENABLED"); + } + if (globalTakeoverEnabledInput.kind === "invalid") { + throw new Error("INVALID_GLOBAL_TAKEOVER_ENABLED"); + } + if (projectId !== "master-agent") { + if ( + modelOverrideInput.kind !== "preserve" || + reasoningEffortInput.kind !== "preserve" || + promptOverrideInput.kind !== "preserve" || + backendOverrideInput.kind !== "preserve" || + globalTakeoverEnabledInput.kind !== "preserve" + ) { + throw new Error("PROJECT_AGENT_CONTROLS_SCOPE_RESTRICTED"); + } + } else if (takeoverEnabledInput.kind !== "preserve") { + throw new Error("MASTER_AGENT_TAKEOVER_SCOPE_RESTRICTED"); + } return mutateStateIfChanged((state) => { const project = state.projects.find((item) => item.id === projectId); @@ -4021,18 +4113,42 @@ export async function updateProjectAgentControls( : backendOverrideInput.kind === "clear" ? undefined : currentControls?.backendOverride; + const takeoverEnabled = + takeoverEnabledInput.kind === "set" + ? takeoverEnabledInput.value + : takeoverEnabledInput.kind === "clear" + ? undefined + : currentControls?.takeoverEnabled; + const globalTakeoverEnabled = + globalTakeoverEnabledInput.kind === "set" + ? globalTakeoverEnabledInput.value + : globalTakeoverEnabledInput.kind === "clear" + ? undefined + : currentControls?.globalTakeoverEnabled; const currentModelOverride = currentControls?.modelOverride; const currentReasoningEffortOverride = currentControls?.reasoningEffortOverride; const currentPromptOverride = currentControls?.promptOverride; const currentBackendOverride = currentControls?.backendOverride; + const currentTakeoverEnabled = currentControls?.takeoverEnabled; + const currentGlobalTakeoverEnabled = currentControls?.globalTakeoverEnabled; if ( currentModelOverride === modelOverride && currentReasoningEffortOverride === reasoningEffortOverride && currentPromptOverride === promptOverride && - currentBackendOverride === backendOverride + currentBackendOverride === backendOverride && + currentTakeoverEnabled === takeoverEnabled && + currentGlobalTakeoverEnabled === globalTakeoverEnabled ) { - return { result: currentControls, changed: false }; + return { + result: applyDerivedTakeoverControls( + state, + projectId, + normalizedAccount ?? undefined, + currentControls ?? null, + ), + changed: false, + }; } const nextControls = { @@ -4040,6 +4156,8 @@ export async function updateProjectAgentControls( reasoningEffortOverride, promptOverride, backendOverride, + takeoverEnabled, + globalTakeoverEnabled, updatedAt: nowIso(), } satisfies ProjectAgentControls; const normalizedControls = normalizeProjectAgentControls(nextControls) ?? null; @@ -4061,7 +4179,15 @@ export async function updateProjectAgentControls( project.threadMeta.updatedAt = nextControls.updatedAt; project.updatedAt = nextControls.updatedAt; - return { result: normalizedControls, changed: true }; + return { + result: applyDerivedTakeoverControls( + state, + projectId, + normalizedAccount ?? undefined, + normalizedControls, + ), + changed: true, + }; }); } diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index c025304..a1ca264 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -170,6 +170,7 @@ function buildAgentControlsDigest(agentControls?: ProjectAgentControls | null) { `reasoning=${agentControls.reasoningEffortOverride ?? "默认"}`, `backend=${agentControls.backendOverride ?? "默认"}`, `prompt=${agentControls.promptOverride ? "已配置" : "默认"}`, + `global_takeover=${agentControls.globalTakeoverEnabled ? "开启" : "关闭"}`, ].join(" "); } diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index 2533063..dde575d 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -600,19 +600,40 @@ function resolveProjectAgentControls( projectId: string, account?: string, ) { - if (projectId !== "master-agent") { - return undefined; - } const normalizedAccount = account?.trim(); - if (normalizedAccount) { - const scoped = state.userProjectAgentControls.find( - (item) => item.projectId === projectId && item.account === normalizedAccount, - ); - if (scoped?.controls) { - return scoped.controls; - } + const scoped = normalizedAccount + ? ( + state.userProjectAgentControls.find( + (item) => item.projectId === projectId && item.account === normalizedAccount, + ) ?? null + ) + : null; + const projectControls = scoped?.controls ?? state.projects.find((item) => item.id === projectId)?.agentControls ?? null; + if (projectId === "master-agent") { + return projectControls; } - return state.projects.find((item) => item.id === projectId)?.agentControls ?? null; + const globalControls = normalizedAccount + ? ( + state.userProjectAgentControls.find( + (item) => item.projectId === "master-agent" && item.account === normalizedAccount, + )?.controls ?? state.projects.find((item) => item.id === "master-agent")?.agentControls ?? null + ) + : state.projects.find((item) => item.id === "master-agent")?.agentControls ?? null; + const explicitTakeover = projectControls?.takeoverEnabled; + const inheritedGlobalTakeover = globalControls?.globalTakeoverEnabled; + const effectiveTakeoverEnabled = + explicitTakeover !== undefined ? explicitTakeover : Boolean(inheritedGlobalTakeover); + const takeoverInheritedFromGlobal = + explicitTakeover === undefined && inheritedGlobalTakeover !== undefined; + if (!projectControls && !takeoverInheritedFromGlobal && !effectiveTakeoverEnabled) { + return null; + } + return { + ...(projectControls ?? { updatedAt: globalControls?.updatedAt ?? new Date().toISOString() }), + updatedAt: projectControls?.updatedAt ?? globalControls?.updatedAt ?? new Date().toISOString(), + effectiveTakeoverEnabled, + takeoverInheritedFromGlobal, + }; } export function getProjectDetailView(state: BossState, projectId: string, account?: string): ProjectDetailView | null { @@ -652,7 +673,7 @@ export function getProjectDetailView(state: BossState, projectId: string, accoun return { project, - agentControls: project.id === "master-agent" ? resolveProjectAgentControls(state, projectId, account) : undefined, + agentControls: resolveProjectAgentControls(state, projectId, account), devices: state.devices.filter((device) => project.deviceIds.includes(device.id)), masterIdentity: projectId === "master-agent" ? getProjectMasterIdentity(state) : undefined, activeThreadContexts, diff --git a/tests/master-agent-chat-controls.test.ts b/tests/master-agent-chat-controls.test.ts index db36abb..9a87ab8 100644 --- a/tests/master-agent-chat-controls.test.ts +++ b/tests/master-agent-chat-controls.test.ts @@ -357,6 +357,95 @@ test("master-agent 对话控制路由单字段更新不会清掉另一字段", a assert.equal(payload.controls?.reasoningEffortOverride, "low"); }); +test("全局接管默认会透传到普通线程会话详情", async () => { + await setup(); + const projectId = await ensureOrdinaryProject("ordinary-takeover-project"); + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + const headers = { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }; + + const response = await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers, + body: JSON.stringify({ + globalTakeoverEnabled: true, + }), + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assert.equal(response.status, 200); + + const projectResponse = await getProjectRoute( + new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}`, { + method: "GET", + headers, + }), + { params: Promise.resolve({ projectId }) }, + ); + assert.equal(projectResponse.status, 200); + const payload = (await projectResponse.json()) as { + ok: boolean; + agentControls: { + effectiveTakeoverEnabled?: boolean; + takeoverInheritedFromGlobal?: boolean; + updatedAt?: string; + } | null; + }; + assert.equal(payload.ok, true); + assert.equal(payload.agentControls?.effectiveTakeoverEnabled, true); + assert.equal(payload.agentControls?.takeoverInheritedFromGlobal, true); +}); + +test("普通线程会话可以单独关闭主 Agent 协同接管并覆盖全局默认", async () => { + await setup(); + const projectId = await ensureOrdinaryProject("ordinary-project-override"); + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + const headers = { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }; + + await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers, + body: JSON.stringify({ + globalTakeoverEnabled: true, + }), + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + + const response = await postAgentControlsRoute( + new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}/agent-controls`, { + method: "POST", + headers, + body: JSON.stringify({ + takeoverEnabled: false, + }), + }), + { params: Promise.resolve({ projectId }) }, + ); + assert.equal(response.status, 200); + + const detail = getProjectDetailView(await readState(), projectId, "17600003315"); + assert.equal(detail?.agentControls?.effectiveTakeoverEnabled, false); + assert.equal(detail?.agentControls?.takeoverInheritedFromGlobal, false); +}); + test("master-agent 对话控制 POST 清空后仍稳定回传 controls null", async () => { await setup(); @@ -406,7 +495,7 @@ test("master-agent 对话控制 POST 清空后仍稳定回传 controls null", as assert.equal(clearPayload.controls, null); }); -test("非 master-agent 项目详情不应回传 agentControls 字段", async () => { +test("普通线程项目详情会回传接管控制占位", async () => { await setup(); const ordinaryProjectId = await ensureOrdinaryProject(); @@ -430,7 +519,8 @@ test("非 master-agent 项目详情不应回传 agentControls 字段", async () assert.equal(response.status, 200); const payload = (await response.json()) as Record; assert.equal(payload.ok, true); - assert.equal(Object.prototype.hasOwnProperty.call(payload, "agentControls"), false); + assert.equal(Object.prototype.hasOwnProperty.call(payload, "agentControls"), true); + assert.equal(payload.agentControls, null); }); test("master-agent 对话控制 POST 允许当前用户修改自己的 master-agent 会话配置", async () => { @@ -880,7 +970,7 @@ test("GET /agent-controls 在未显式设置 BOSS_STATE_FILE 时仍可正常读 assert.match(stdout, /OK/); }); -test("GET /agent-controls rejects ordinary projects", async () => { +test("GET /agent-controls supports ordinary projects for takeover settings", async () => { await setup(); const ordinaryProjectId = await ensureOrdinaryProject(); @@ -901,8 +991,8 @@ test("GET /agent-controls rejects ordinary projects", async () => { { params: Promise.resolve({ projectId: ordinaryProjectId }) }, ); - assert.equal(response.status, 404); - assert.equal((await response.json()).message, "PROJECT_NOT_FOUND"); + assert.equal(response.status, 200); + assert.equal((await response.json()).controls, null); }); test("POST /agent-controls rejects unknown-key payload and preserves controls", async () => { @@ -980,7 +1070,7 @@ test("master-agent 对话控制 POST 会稳定拒绝非法 backendOverride", asy assert.equal(payload.message, "INVALID_BACKEND_OVERRIDE"); }); -test("master-agent controls helper 不会写入普通项目", async () => { +test("普通项目不会接受 master-agent 专属控制字段", async () => { await setup(); const ordinaryProjectId = await ensureOrdinaryProject(); @@ -990,7 +1080,7 @@ test("master-agent controls helper 不会写入普通项目", async () => { modelOverride: "gpt-5.4", reasoningEffortOverride: "low", }), - /MASTER_AGENT_CONTROLS_SCOPE_RESTRICTED/, + /PROJECT_AGENT_CONTROLS_SCOPE_RESTRICTED/, ); const state = await readState();