From 7c6101f22b69046239c712cac5c82816a85f9132 Mon Sep 17 00:00:00 2001 From: kris Date: Mon, 30 Mar 2026 17:42:21 +0800 Subject: [PATCH] fix: restore master agent relay guidance --- README.md | 4 + .../com/hyzq/boss/AiAccountsActivity.java | 34 +++++++- .../java/com/hyzq/boss/BossApiClient.java | 71 ++++++++++++++--- .../boss/BossApiClientDispatchPlansTest.java | 26 ++++++ .../api_and_service_inventory_cn.md | 3 +- .../current_runtime_and_deploy_status_cn.md | 4 + src/components/ai-accounts-client.tsx | 36 +++++++++ src/lib/boss-master-agent.ts | 77 ++++++++++++++++-- tests/ai-account-validation.test.ts | 79 +++++++++++++++++++ 9 files changed, 312 insertions(+), 22 deletions(-) create mode 100644 tests/ai-account-validation.test.ts diff --git a/README.md b/README.md index 95788ec..2409be1 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ - `GET http://127.0.0.1:3000/api/v1/conversations` 正常 - `GET http://127.0.0.1:3000/api/v1/projects/master-agent` 正常,主 Agent 项目页已能显示最近 APP 日志 - `GET http://127.0.0.1:3000/api/v1/accounts` 正常,已返回主 GPT / 备用 GPT / API 容灾账号摘要 +- `POST http://127.0.0.1:3000/api/v1/accounts/master-codex-primary/validate` 正常,已验证会明确提示“主 GPT 不在手机里直接登录”,并校验绑定设备在线状态 - `GET http://127.0.0.1:3000/api/v1/devices/mac-studio/skills` 正常,已返回本机同步 Skill 列表 - `POST http://127.0.0.1:3000/api/auth/login` 正常,会写入 `boss_session` Cookie - `GET http://127.0.0.1:3000/api/auth/session` 正常 @@ -279,6 +280,8 @@ npm run aab:release - `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新 - 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill,并支持一键复制调用语句 - 我的页新增 `AI 账号` 入口,`/me/ai-accounts` 会展示主 GPT / 备用 GPT / API 容灾,并明确主链路优先走已登录 `ChatGPT Plus / Codex` 的 `Master Codex Node` +- `AI 账号` 页面当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth;主 GPT 的登录动作必须在绑定电脑上的 Codex / ChatGPT Plus 会话里完成,再回手机端点“测试连接 / 校验连接” +- `POST /api/v1/accounts/[accountId]/validate` 当前不再只看 `nodeId`;对 `master_codex_node` 会同时校验绑定设备是否在线,并在设备离线时返回明确的降级说明 - API 容灾当前不走服务器预置 Key,而是由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号 - 设备页当前只展示已接入生产链路的设备,历史演示脏数据已经从正式设备视图、运维视图和审计视图中剔除 - 本机 `local-agent` 现在会直接从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 动态发现真实 Codex 线程,并在 heartbeat 里上报 `projectCandidates` @@ -314,3 +317,4 @@ npm run aab:release - 图片 / PDF / 文本默认自动进入主 Agent 附件分析;视频 / Office / 大文件默认手动触发 - 当前采用“极轻云 + 本地设备端”的路线,云端只承载 Web、轻 API 和状态文件 - 服务器侧主 Agent 对话能否返回真实大模型回复,依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;服务器本身不直接持有主 GPT 会话 +- 原生 Android 当前对 `master-agent` 聊天消息已单独放宽读超时到 `65s`;不会再因为默认 `12s` 超时把“主 Agent 无响应”误判成对话失败 diff --git a/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java index 8a56dcf..bd63c60 100644 --- a/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java @@ -53,7 +53,7 @@ public class AiAccountsActivity extends BossScreenActivity { this, "AI 账号", "这里统一管理主 GPT、备用 GPT 与 API 容灾账号。", - "轻点条目可编辑,按钮可切换、校验或删除。", + "主 GPT 的登录发生在绑定设备上的 Codex / ChatGPT Plus,会在这里给登录指引。", null, null )); @@ -143,16 +143,44 @@ public class AiAccountsActivity extends BossScreenActivity { activate.setEnabled(!account.optBoolean("isActive")); activate.setOnClickListener(v -> activateAccount(account)); + Button loginGuide = null; + if ("master_codex_node".equals(account.optString("provider"))) { + loginGuide = BossUi.buildMiniActionButton(this, "登录指引", false); + loginGuide.setOnClickListener(v -> showMasterNodeLoginGuide(account)); + } + Button validate = BossUi.buildMiniActionButton(this, "校验连接", false); validate.setOnClickListener(v -> validateAccount(account)); Button delete = BossUi.buildMiniActionButton(this, "删除账号", false); delete.setOnClickListener(v -> confirmDeleteAccount(account)); - card.addView(BossUi.buildInlineActionRow(this, activate, validate, delete)); + card.addView(loginGuide == null + ? BossUi.buildInlineActionRow(this, activate, validate, delete) + : BossUi.buildInlineActionRow(this, activate, loginGuide, validate, delete)); return card; } + private void showMasterNodeLoginGuide(JSONObject account) { + String nodeLabel = account.optString("nodeLabel"); + if (nodeLabel == null || nodeLabel.trim().isEmpty()) { + nodeLabel = account.optString("nodeId"); + } + if (nodeLabel == null || nodeLabel.trim().isEmpty()) { + nodeLabel = "绑定设备"; + } + + String message = "主 GPT 不在手机里直接登录。\n\n" + + "请到绑定设备 " + nodeLabel + " 上打开 Codex / ChatGPT Plus 会话完成登录。\n" + + "登录完成后,回到这里点“校验连接”,确认主 Agent relay 已经接通。"; + + new AlertDialog.Builder(this) + .setTitle("主 GPT 登录指引") + .setMessage(message) + .setPositiveButton("知道了", null) + .show(); + } + private void openAccountEditor(@Nullable JSONObject existing, @Nullable String apiKeyHint) { final android.widget.EditText labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false); final android.widget.EditText displayNameInput = BossUi.buildInput(this, "显示名称", false); @@ -313,7 +341,7 @@ public class AiAccountsActivity extends BossScreenActivity { BossApiClient.ApiResponse response = apiClient.validateAccount(account.optString("accountId")); if (!response.ok()) throw new IllegalStateException(response.message()); runOnUiThread(() -> { - showMessage("账号校验成功"); + showMessage(response.message()); reload(); }); } catch (Exception error) { 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 fe9fd74..ad70638 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -27,6 +27,9 @@ import java.util.List; import java.util.Map; public class BossApiClient { + private static final int DEFAULT_CONNECT_TIMEOUT_MS = 12000; + private static final int DEFAULT_READ_TIMEOUT_MS = 12000; + private static final int MASTER_AGENT_READ_TIMEOUT_MS = 65000; private static final String PREFS_NAME = "boss_native_client"; private static final String KEY_SESSION_COOKIE = "session_cookie"; private static final String KEY_RESTORE_TOKEN = "restore_token"; @@ -130,7 +133,14 @@ public class BossApiClient { JSONObject payload = new JSONObject(); payload.put("body", body); payload.put("kind", kind); - return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/messages", payload); + int readTimeoutMs = "master-agent".equals(projectId) ? MASTER_AGENT_READ_TIMEOUT_MS : DEFAULT_READ_TIMEOUT_MS; + return requestWithRestoreRaw( + "POST", + "/api/v1/projects/" + encode(projectId) + "/messages", + payload.toString(), + DEFAULT_CONNECT_TIMEOUT_MS, + readTimeoutMs + ); } public ApiResponse uploadAttachment( @@ -157,7 +167,7 @@ public class BossApiClient { String sourceType ) throws IOException, JSONException { HttpURLConnection connection = openConnection("/api/v1/projects/" + encode(projectId) + "/attachments"); - prepareConnection(connection, "POST"); + prepareConnection(connection, "POST", DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS); connection.setDoOutput(true); String boundary = "BossBoundary" + System.currentTimeMillis(); @@ -373,27 +383,61 @@ public class BossApiClient { } private ApiResponse requestWithRestore(String method, String path, JSONObject body) throws IOException, JSONException { - return requestWithRestoreRaw(method, path, body == null ? null : body.toString()); + return requestWithRestoreRaw( + method, + path, + body == null ? null : body.toString(), + DEFAULT_CONNECT_TIMEOUT_MS, + DEFAULT_READ_TIMEOUT_MS + ); } private ApiResponse requestWithRestoreRaw(String method, String path, @Nullable String body) throws IOException, JSONException { - ApiResponse response = requestRaw(method, path, body, true); + return requestWithRestoreRaw(method, path, body, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS); + } + + private ApiResponse requestWithRestoreRaw( + String method, + String path, + @Nullable String body, + int connectTimeoutMs, + int readTimeoutMs + ) throws IOException, JSONException { + ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs); if (response.statusCode == 401 && !getRestoreToken().isEmpty()) { ApiResponse restored = restoreSession(); if (restored.ok()) { - return requestRaw(method, path, body, true); + return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs); } } return response; } private ApiResponse request(String method, String path, JSONObject body, boolean expectProtected) throws IOException, JSONException { - return requestRaw(method, path, body == null ? null : body.toString(), expectProtected); + return requestRaw( + method, + path, + body == null ? null : body.toString(), + expectProtected, + DEFAULT_CONNECT_TIMEOUT_MS, + DEFAULT_READ_TIMEOUT_MS + ); } private ApiResponse requestRaw(String method, String path, @Nullable String body, boolean expectProtected) throws IOException, JSONException { + return requestRaw(method, path, body, expectProtected, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS); + } + + private ApiResponse requestRaw( + String method, + String path, + @Nullable String body, + boolean expectProtected, + int connectTimeoutMs, + int readTimeoutMs + ) throws IOException, JSONException { HttpURLConnection connection = openConnection(path); - prepareConnection(connection, method); + prepareConnection(connection, method, connectTimeoutMs, readTimeoutMs); if (body != null) { connection.setDoOutput(true); @@ -411,10 +455,15 @@ public class BossApiClient { return (HttpURLConnection) new URL(baseUrl + path).openConnection(); } - private void prepareConnection(HttpURLConnection connection, String method) throws IOException { + private void prepareConnection( + HttpURLConnection connection, + String method, + int connectTimeoutMs, + int readTimeoutMs + ) throws IOException { connection.setRequestMethod(method); - connection.setConnectTimeout(12000); - connection.setReadTimeout(12000); + connection.setConnectTimeout(connectTimeoutMs); + connection.setReadTimeout(readTimeoutMs); connection.setUseCaches(false); connection.setDoInput(true); connection.setRequestProperty("Accept", "application/json"); @@ -500,7 +549,7 @@ public class BossApiClient { boolean expectProtected ) throws IOException { HttpURLConnection connection = openConnection("/api/v1/attachments/" + encode(attachmentId) + "/download"); - prepareConnection(connection, "GET"); + prepareConnection(connection, "GET", DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS); int statusCode = connection.getResponseCode(); captureSessionCookie(connection.getHeaderFields()); diff --git a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java index 2ec537a..01e59dc 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java @@ -52,6 +52,20 @@ public class BossApiClientDispatchPlansTest { assertEquals("{\"approvedTargetProjectIds\":[\"target-1\",\"target-2\"]}", connection.requestBody()); } + @Test + public void sendProjectMessageUsesExtendedReadTimeoutForMasterAgent() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/messages")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + BossApiClient.ApiResponse response = apiClient.sendProjectMessage("master-agent", "你好", "text"); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/master-agent/messages", apiClient.lastPath); + assertEquals("POST", connection.requestMethodValue); + assertEquals(12000, connection.connectTimeoutValue); + assertEquals(65000, connection.readTimeoutValue); + } + private static final class RecordingBossApiClient extends BossApiClient { private final RecordingConnection connection; private String lastPath = ""; @@ -82,6 +96,8 @@ public class BossApiClientDispatchPlansTest { private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream(); private final Map requestHeaders = new HashMap<>(); private String requestMethodValue = "GET"; + private int connectTimeoutValue = 0; + private int readTimeoutValue = 0; RecordingConnection(URL url) { super(url); @@ -108,6 +124,16 @@ public class BossApiClientDispatchPlansTest { requestHeaders.put(key, value); } + @Override + public void setConnectTimeout(int timeout) { + connectTimeoutValue = timeout; + } + + @Override + public void setReadTimeout(int timeout) { + readTimeoutValue = timeout; + } + @Override public String getRequestProperty(String key) { return requestHeaders.get(key); diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index b735b6d..2782a28 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -430,10 +430,11 @@ - 用途:校验指定 AI 账号是否可用 - 当前行为: - - `master_codex_node`:校验是否配置节点 ID,并提示由 local-agent relay 执行 + - `master_codex_node`:明确提示“主 GPT 不在手机里直接登录”,并同时校验绑定设备是否在线;在线时返回 `ready`,离线时返回 `degraded` - `openai_api`:实际调用模型返回探针结果 - 当前约束: - `openai_api` 的容灾 Key 由用户在 APP 内配置,不走服务器默认预置 + - 手机端当前没有直接的 ChatGPT OAuth 登录流程;主 GPT 必须先在绑定电脑上的 Codex / ChatGPT Plus 会话里完成登录 #### `POST /api/v1/projects/[projectId]/forwards` diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index de6957a..995b0a7 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -12,6 +12,7 @@ - 会话聚合接口:`http://127.0.0.1:3000/api/v1/conversations` - 主 Agent 项目详情:`http://127.0.0.1:3000/api/v1/projects/master-agent` - AI 账号摘要接口:`http://127.0.0.1:3000/api/v1/accounts` +- AI 账号校验接口:`POST http://127.0.0.1:3000/api/v1/accounts/[accountId]/validate` - 设备 Skill 同步接口:`http://127.0.0.1:3000/api/v1/devices/mac-studio/skills` - 登录接口:`POST http://127.0.0.1:3000/api/auth/login` - 登录态接口:`GET http://127.0.0.1:3000/api/auth/session` @@ -108,6 +109,8 @@ cd /Users/kris/code/boss - 原生会话页当前的刷新失败策略已改成按当前 tab 独立判错:`会话` 不会再因为 `设备 / OTA / 设置` 的旁路请求失败而整体提示“刷新失败” - 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新 - 我的页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` 六个一级入口;`AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的 `Master Codex Node` +- `AI 账号` 页当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth;`主 GPT` 需要先在绑定电脑上的 Codex / ChatGPT Plus 会话里登录,再回手机端点“测试连接 / 校验连接” +- `POST /api/v1/accounts/[accountId]/validate` 当前对 `master_codex_node` 不再只看 `nodeId`,还会同时校验绑定设备是否在线;设备离线时返回 `degraded` 和清晰的人类可读提示 - 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本` - 主 Agent 同步等待窗口当前为 55 秒;若本机 Codex 节点回复更慢,项目页仍会通过 SSE 在任务完成后自动刷新出真实回复 - `GET /api/v1/app-logs` 当前已支持登录态分页查询 @@ -147,6 +150,7 @@ cd /Users/kris/code/boss - `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理 - 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列 - 当前设备导入决议已经会先落 `device_import_resolution` master task 再写回结果,但决议内容仍是服务端 heuristic 版;下一阶段可再升级成真正通过 `local-agent -> codex exec` 参与理解的主 Agent 决议 +- 原生 Android 当前对 `master-agent` 聊天消息已单独放宽读超时到 `65s`;之前默认 `12s` 会把等待 `Master Codex Node / local-agent` 回写的长请求误判成“主 Agent 无响应” ## 2. 服务器状态 diff --git a/src/components/ai-accounts-client.tsx b/src/components/ai-accounts-client.tsx index b085e00..628fc81 100644 --- a/src/components/ai-accounts-client.tsx +++ b/src/components/ai-accounts-client.tsx @@ -57,6 +57,22 @@ function emptyDraft(): AccountDraft { }; } +function buildMasterNodeLoginGuide(account: { + nodeLabel?: string; + nodeId?: string; + statusLabel?: string; +}) { + const nodeLabel = account.nodeLabel?.trim() || account.nodeId?.trim() || "绑定设备"; + return [ + `主 GPT 不在手机里直接登录。`, + `请到绑定设备 ${nodeLabel} 上打开 Codex / ChatGPT Plus 会话完成登录。`, + `登录完成后,回到这里点“测试连接”确认 relay 和主 Agent 已接通。`, + account.statusLabel ? `当前状态:${account.statusLabel}` : null, + ] + .filter(Boolean) + .join("\n"); +} + function draftFromAccount(account: AiAccountSummary): AccountDraft { return { label: account.label, @@ -142,6 +158,7 @@ export function AiAccountsClient({ const [newDraft, setNewDraft] = useState(emptyDraft()); const [busyKey, setBusyKey] = useState(null); const [message, setMessage] = useState(""); + const [guideAccountId, setGuideAccountId] = useState(null); const accountDrafts = useMemo( () => @@ -437,6 +454,19 @@ export function AiAccountsClient({
+ {account.provider === "master_codex_node" ? ( + + ) : null} ) : null}
+ + {account.provider === "master_codex_node" && guideAccountId === account.accountId ? ( +
+ {buildMasterNodeLoginGuide(account)} +
+ ) : null} ); })} diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index 80429fe..d192c74 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -753,13 +753,55 @@ export async function validateAiAccountConnection(accountId: string) { } if (account.provider === "master_codex_node") { + const state = await readState(); + const nodeId = account.nodeId?.trim() || state.user.boundDeviceId || ""; + const boundDevice = state.devices.find((device) => device.id === nodeId); + const boundNodeLabel = + account.nodeLabel?.trim() || + boundDevice?.name || + state.user.boundCodexNodeLabel || + state.user.boundDeviceId || + "绑定设备"; + + if (!nodeId) { + await updateAiAccountHealth({ + accountId: account.accountId, + status: "needs_login", + lastError: "MASTER_CODEX_NODE_NOT_CONFIGURED", + lastValidatedAt: new Date().toISOString(), + }); + return { + ok: false as const, + status: "needs_login" as const, + message: `主 GPT 不在手机里直接登录。请先在绑定设备(例如 ${boundNodeLabel})上的 Codex / ChatGPT Plus 会话里登录,并填写正确的节点 ID,再回来校验连接。`, + }; + } + + if (!boundDevice || boundDevice.status !== "online") { + await updateAiAccountHealth({ + accountId: account.accountId, + status: "degraded", + lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE", + lastValidatedAt: new Date().toISOString(), + }); + return { + ok: false as const, + status: "degraded" as const, + message: `主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过该节点对话。请先在这台设备上登录 Codex / ChatGPT Plus,并确保 local-agent 在线。`, + }; + } + + await updateAiAccountHealth({ + accountId: account.accountId, + status: "ready", + lastError: undefined, + lastValidatedAt: new Date().toISOString(), + lastUsedAt: boundDevice.lastSeenAt || new Date().toISOString(), + }); return { - ok: Boolean(account.nodeId?.trim()) as boolean, - status: account.nodeId?.trim() ? "ready" : "needs_login", - message: - account.nodeId?.trim() - ? "Master Codex Node 已配置。主 Agent 会通过 local-agent relay 把任务转交给该节点上的 Codex。" - : "请先填写 Master Codex Node 的节点 ID,再让 local-agent 认领主 Agent 任务。", + ok: true as const, + status: "ready" as const, + message: `主 GPT 不在手机里直接登录。当前已通过绑定设备 ${boundNodeLabel} 接好 Master Codex Node,主 Agent 会把任务转交给这台设备上的 Codex / ChatGPT Plus 会话。`, }; } @@ -811,6 +853,27 @@ export async function replyToMasterAgentUserMessage(params: { if (runtime.account.provider === "master_codex_node") { const state = await readState(); const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio"; + const boundDevice = state.devices.find((device) => device.id === deviceId); + const boundNodeLabel = + runtime.account.nodeLabel?.trim() || + boundDevice?.name || + state.user.boundCodexNodeLabel || + deviceId; + + if (!boundDevice || boundDevice.status !== "online") { + await updateAiAccountHealth({ + accountId: runtime.account.accountId, + status: "degraded", + lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE", + lastValidatedAt: new Date().toISOString(), + }); + await appendMasterAgentSystemReply( + `主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus,并确保 local-agent 在线后再重试。`, + `主 Agent · ${runtime.summary.roleLabel}`, + ); + return { ok: false as const, reason: "MASTER_NODE_OFFLINE" }; + } + const task = await queueMasterAgentTask({ requestMessageId: params.requestMessageId ?? "master-agent-manual", requestText: params.requestText, @@ -845,7 +908,7 @@ export async function replyToMasterAgentUserMessage(params: { await appendMasterAgentSystemReply( [ - `当前主控身份是 ${runtime.summary.roleLabel},任务已经转交到 ${runtime.account.nodeLabel ?? deviceId} 的 Master Codex Node。`, + `当前主控身份是 ${runtime.summary.roleLabel},任务已经转交到 ${boundNodeLabel} 的 Master Codex Node。`, "如果本机 Codex 节点在线,回复会在稍后自动回写到当前会话。", ].join(""), `主 Agent · ${runtime.summary.roleLabel}`, diff --git a/tests/ai-account-validation.test.ts b/tests/ai-account-validation.test.ts new file mode 100644 index 0000000..2292eaf --- /dev/null +++ b/tests/ai-account-validation.test.ts @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { NextRequest } from "next/server"; + +let runtimeRoot = ""; +let validateAiAccountConnection: (typeof import("../src/lib/boss-master-agent"))["validateAiAccountConnection"]; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let deviceHeartbeatRoute: (typeof import("../src/app/api/device-heartbeat/route"))["POST"]; + +async function setup() { + if (runtimeRoot) return; + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-ai-account-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [masterAgent, data, heartbeatModule] = await Promise.all([ + import("../src/lib/boss-master-agent.ts"), + import("../src/lib/boss-data.ts"), + import("../src/app/api/device-heartbeat/route.ts"), + ]); + + validateAiAccountConnection = masterAgent.validateAiAccountConnection; + readState = data.readState; + deviceHeartbeatRoute = heartbeatModule.POST; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test("validateAiAccountConnection explains master node login happens on the bound device", async () => { + await setup(); + + const result = await validateAiAccountConnection("master-codex-primary"); + + assert.equal(result.ok, true); + assert.equal(result.status, "ready"); + assert.match(result.message, /不在手机里直接登录/); + assert.match(result.message, /Mac Studio|本机 Codex/); +}); + +test("validateAiAccountConnection reports degraded when the bound master node device is offline", async () => { + await setup(); + const state = await readState(); + const device = state.devices.find((item) => item.id === "mac-studio"); + assert.ok(device, "expected default mac-studio seed device"); + + const heartbeatResponse = await deviceHeartbeatRoute( + new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + deviceId: device!.id, + token: device!.token, + name: device!.name, + avatar: device!.avatar, + account: device!.account, + status: "offline", + quota5h: device!.quota5h, + quota7d: device!.quota7d, + projects: device!.projects, + endpoint: device!.endpoint, + }), + }), + ); + assert.equal(heartbeatResponse.status, 200); + + const result = await validateAiAccountConnection("master-codex-primary"); + + assert.equal(result.ok, false); + assert.equal(result.status, "degraded"); + assert.match(result.message, /当前不在线|不在线/); +});