feat: add master-agent takeover controls
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 + " 个参与线程";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ function buildAgentControlsDigest(agentControls?: ProjectAgentControls | null) {
|
||||
`reasoning=${agentControls.reasoningEffortOverride ?? "默认"}`,
|
||||
`backend=${agentControls.backendOverride ?? "默认"}`,
|
||||
`prompt=${agentControls.promptOverride ? "已配置" : "默认"}`,
|
||||
`global_takeover=${agentControls.globalTakeoverEnabled ? "开启" : "关闭"}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user