feat: add master-agent takeover controls

This commit is contained in:
kris
2026-04-05 08:45:07 +08:00
parent 52f7d08b9e
commit 2a5962f767
10 changed files with 437 additions and 46 deletions

View File

@@ -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);
}

View File

@@ -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 + " 个参与线程";

View File

@@ -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<String> 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);

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,
);

View File

@@ -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,
};
});
}

View File

@@ -170,6 +170,7 @@ function buildAgentControlsDigest(agentControls?: ProjectAgentControls | null) {
`reasoning=${agentControls.reasoningEffortOverride ?? "默认"}`,
`backend=${agentControls.backendOverride ?? "默认"}`,
`prompt=${agentControls.promptOverride ? "已配置" : "默认"}`,
`global_takeover=${agentControls.globalTakeoverEnabled ? "开启" : "关闭"}`,
].join(" ");
}

View File

@@ -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,

View File

@@ -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<string, unknown>;
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();