diff --git a/README.md b/README.md index 653e277..256613e 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,7 @@ npm run aab:release - 当前本机 Codex 节点 `mac-studio` 已绑定到 `17600003315` - 主 Agent 对话当前真实执行链路是:`Boss Web -> 写入用户消息 -> 返回 queued/running -> master-agent task queue -> local-agent / OpenAI API -> complete task -> project ledger` - `master-agent` 单聊当前已改成“快速入队 + 异步回流”:发送后会立即返回任务包和 `masterReplyState`,前台先显示“主 Agent 思考中”,真实回复稍后自动回写到账本 +- 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示 - `master-agent` 单聊当前已支持当前对话级别的 `模型 / 推理强度` 覆盖,服务端会优先把该会话的 `agentControls` 用到实际 OpenAI 回复和 Master Codex Node 执行 prompt 中 - 原生 Android 当前在 `master-agent` 聊天页右上角提供微信式 `...` 菜单,菜单项包含 `模型 / 推理强度 / 会话信息 / 刷新` - 服务器已经部署 `Postfix + Dovecot`,邮箱别名 `verify@boss.hyzq.net` / `no-reply@boss.hyzq.net` 当前会投递到本机 `bossmail` 邮箱 diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index 19ad7a3..f6a3e50 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -64,6 +64,7 @@ public class ProjectDetailActivity extends BossScreenActivity { private boolean renderForcedScrollToBottom; private boolean conversationInfoReady; private boolean masterAgentReplyWaiting; + private boolean masterAgentReplyTimedOut; private @Nullable String masterAgentReplyBaselineMessageId; private String currentScreenTitle; private String currentScreenSubtitle; @@ -327,15 +328,17 @@ public class ProjectDetailActivity extends BossScreenActivity { appendContent(BossUi.buildMessagePlaceholder(this, "还没有项目消息,先发一条开始对话。")); } - boolean masterAgentStillWaiting = isMasterAgentConversation() - && masterAgentReplyWaiting - && !ProjectChatUiState.hasReplyBeyondBaseline(project, masterAgentReplyBaselineMessageId); - if (isMasterAgentConversation() && masterAgentReplyWaiting && !masterAgentStillWaiting) { - masterAgentReplyWaiting = false; - masterAgentReplyBaselineMessageId = null; + boolean masterAgentHasReply = isMasterAgentConversation() + && ProjectChatUiState.hasReplyBeyondBaseline(project, masterAgentReplyBaselineMessageId); + if (masterAgentHasReply) { + clearMasterAgentReplyState(); } - if (masterAgentStillWaiting) { - appendContent(buildMasterAgentThinkingPlaceholder()); + if (isMasterAgentConversation()) { + if (masterAgentReplyWaiting) { + appendContent(buildMasterAgentReplyStateView(false)); + } else if (masterAgentReplyTimedOut) { + appendContent(buildMasterAgentReplyStateView(true)); + } } setRefreshing(false); @@ -1471,8 +1474,31 @@ public class ProjectDetailActivity extends BossScreenActivity { return value.trim(); } - private View buildMasterAgentThinkingPlaceholder() { - return BossUi.buildHintPill(this, "主 Agent 思考中"); + private View buildMasterAgentReplyStateView(boolean timedOut) { + LinearLayout container = new LinearLayout(this); + container.setOrientation(LinearLayout.VERTICAL); + container.addView(BossUi.buildCard( + this, + timedOut ? "主 Agent 回复超时" : "主 Agent 思考中", + timedOut + ? "消息已经发送,但暂时还没有收到回复。你可以继续等待最新结果。" + : "消息已发送,正在等待主 Agent 回复。", + timedOut + ? "超时后不会丢失状态,收到新回复会自动清掉。" + : "稍后收到回复后,这个状态会自动消失。" + )); + if (timedOut) { + Button retryButton = BossUi.buildMiniActionButton(this, "重试等待", true); + retryButton.setOnClickListener(v -> retryMasterAgentReplyWait()); + container.addView(BossUi.buildInlineActionRow(this, retryButton)); + } + return container; + } + + private void clearMasterAgentReplyState() { + masterAgentReplyWaiting = false; + masterAgentReplyTimedOut = false; + masterAgentReplyBaselineMessageId = null; } private void scrollChatToBottom() { @@ -1866,7 +1892,7 @@ public class ProjectDetailActivity extends BossScreenActivity { updateComposerSendButtonState(); setRefreshing(true); showMessage(waitingMessage); - executor.execute(() -> pollUntilReply(waitSpec, includeDispatchPlans)); + enqueueReplyWaitPoll(waitSpec.baselineMessageId, includeDispatchPlans); } private void startMasterAgentReplyWait( @@ -1875,17 +1901,22 @@ public class ProjectDetailActivity extends BossScreenActivity { String waitingMessage ) { masterAgentReplyWaiting = true; + masterAgentReplyTimedOut = false; masterAgentReplyBaselineMessageId = waitSpec.baselineMessageId; composerSending = false; updateComposerSendButtonState(); setRefreshing(false); showMessage(waitingMessage); reload(true); - replyWaitExecutor.execute(() -> pollUntilReply(waitSpec, includeDispatchPlans)); + enqueueReplyWaitPoll(waitSpec.baselineMessageId, includeDispatchPlans); + } + + protected void enqueueReplyWaitPoll(@Nullable String baselineMessageId, boolean includeDispatchPlans) { + replyWaitExecutor.execute(() -> pollUntilReply(baselineMessageId, includeDispatchPlans)); } private void pollUntilReply( - ProjectChatUiState.ReplyWaitSpec waitSpec, + @Nullable String baselineMessageId, boolean includeDispatchPlans ) { long deadlineAt = System.currentTimeMillis() + REPLY_WAIT_TIMEOUT_MS; @@ -1894,7 +1925,7 @@ public class ProjectDetailActivity extends BossScreenActivity { while (!Thread.currentThread().isInterrupted() && System.currentTimeMillis() < deadlineAt) { ProjectSnapshot snapshot = fetchProjectSnapshot(includeDispatchPlans); JSONObject project = snapshot.payload.optJSONObject("project"); - boolean hasReply = ProjectChatUiState.hasReplyBeyondBaseline(project, waitSpec.baselineMessageId); + boolean hasReply = ProjectChatUiState.hasReplyBeyondBaseline(project, baselineMessageId); if (!renderedInitialSnapshot || hasReply) { runOnUiThread(() -> { @@ -1910,10 +1941,7 @@ public class ProjectDetailActivity extends BossScreenActivity { if (hasReply) { runOnUiThread(() -> { - if (isMasterAgentConversation()) { - masterAgentReplyWaiting = false; - masterAgentReplyBaselineMessageId = null; - } + clearMasterAgentReplyState(); composerSending = false; updateComposerSendButtonState(); setRefreshing(false); @@ -1928,19 +1956,19 @@ public class ProjectDetailActivity extends BossScreenActivity { runOnUiThread(() -> { if (isMasterAgentConversation()) { masterAgentReplyWaiting = false; - masterAgentReplyBaselineMessageId = null; + masterAgentReplyTimedOut = true; } composerSending = false; updateComposerSendButtonState(); setRefreshing(false); - showMessage("对方还在处理中,稍后下拉刷新查看最新回复。"); + showMessage("主 Agent 回复超时,可重试等待最新回复。"); reload(false); }); } catch (Exception error) { runOnUiThread(() -> { if (isMasterAgentConversation()) { masterAgentReplyWaiting = false; - masterAgentReplyBaselineMessageId = null; + masterAgentReplyTimedOut = true; } composerSending = false; updateComposerSendButtonState(); @@ -1951,6 +1979,20 @@ public class ProjectDetailActivity extends BossScreenActivity { } } + private void retryMasterAgentReplyWait() { + if (!isMasterAgentConversation() || TextUtils.isEmpty(masterAgentReplyBaselineMessageId)) { + return; + } + masterAgentReplyWaiting = true; + masterAgentReplyTimedOut = false; + composerSending = false; + updateComposerSendButtonState(); + setRefreshing(false); + showMessage("已重新开始等待主 Agent 回复"); + reload(true); + enqueueReplyWaitPoll(masterAgentReplyBaselineMessageId, false); + } + static ChromeBindings buildChromeBindings( ProjectChatUiState.ChromeState chromeState, boolean composerBusy diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java index 27e162a..82f1cd9 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java @@ -253,6 +253,132 @@ public class ProjectDetailActivityUiTest { assertEquals("...", headerAction.getText().toString()); } + @Test + public void renderProjectKeepsMasterAgentWaitingStateVisibleInMessageFlow() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.setField(activity, "conversationInfoReady", true); + ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent"); + ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话"); + ReflectionHelpers.setField(activity, "masterAgentReplyWaiting", true); + ReflectionHelpers.setField(activity, "masterAgentReplyTimedOut", false); + ReflectionHelpers.setField(activity, "masterAgentReplyBaselineMessageId", "msg-user-1"); + + JSONObject project = new JSONObject() + .put("project", new JSONObject() + .put("id", "master-agent") + .put("name", "主 Agent") + .put("messages", new JSONArray() + .put(new JSONObject().put("id", "msg-user-1").put("sender", "user")))); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderProject", + ReflectionHelpers.ClassParameter.from(JSONObject.class, project), + ReflectionHelpers.ClassParameter.from(JSONArray.class, null), + ReflectionHelpers.ClassParameter.from(JSONObject.class, null) + ); + + View content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "主 Agent 思考中")); + assertFalse(viewTreeContainsText(content, "重试等待")); + assertFalse(ReflectionHelpers.getField(activity, "masterAgentReplyTimedOut")); + } + + @Test + public void renderProjectShowsRetryEntryAfterMasterAgentWaitTimesOut() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.setField(activity, "conversationInfoReady", true); + ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent"); + ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话"); + ReflectionHelpers.setField(activity, "masterAgentReplyWaiting", false); + ReflectionHelpers.setField(activity, "masterAgentReplyTimedOut", true); + ReflectionHelpers.setField(activity, "masterAgentReplyBaselineMessageId", "msg-user-1"); + + JSONObject project = new JSONObject() + .put("project", new JSONObject() + .put("id", "master-agent") + .put("name", "主 Agent") + .put("messages", new JSONArray() + .put(new JSONObject().put("id", "msg-user-1").put("sender", "user")))); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderProject", + ReflectionHelpers.ClassParameter.from(JSONObject.class, project), + ReflectionHelpers.ClassParameter.from(JSONArray.class, null), + ReflectionHelpers.ClassParameter.from(JSONObject.class, null) + ); + + View content = activity.findViewById(R.id.screen_content); + View retryButton = findClickableViewContainingText(content, "重试等待"); + assertNotNull(retryButton); + assertTrue(viewTreeContainsText(content, "主 Agent 回复超时")); + + retryButton.performClick(); + + assertTrue(ReflectionHelpers.getField(activity, "masterAgentReplyWaiting")); + assertFalse(ReflectionHelpers.getField(activity, "masterAgentReplyTimedOut")); + assertEquals(1, activity.replyWaitPollCount); + assertEquals("msg-user-1", activity.lastReplyWaitBaselineMessageId); + assertFalse(activity.lastReplyWaitIncludeDispatchPlans); + } + + @Test + public void renderProjectClearsMasterAgentWaitStateAfterNewReplyArrives() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.setField(activity, "conversationInfoReady", true); + ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent"); + ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话"); + ReflectionHelpers.setField(activity, "masterAgentReplyWaiting", true); + ReflectionHelpers.setField(activity, "masterAgentReplyTimedOut", true); + ReflectionHelpers.setField(activity, "masterAgentReplyBaselineMessageId", "msg-user-1"); + + JSONObject project = new JSONObject() + .put("project", new JSONObject() + .put("id", "master-agent") + .put("name", "主 Agent") + .put("messages", new JSONArray() + .put(new JSONObject().put("id", "msg-user-1").put("sender", "user")) + .put(new JSONObject().put("id", "msg-thread-1").put("sender", "assistant")) + .put(new JSONObject().put("id", "msg-thread-2").put("sender", "assistant")))); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderProject", + ReflectionHelpers.ClassParameter.from(JSONObject.class, project), + ReflectionHelpers.ClassParameter.from(JSONArray.class, null), + ReflectionHelpers.ClassParameter.from(JSONObject.class, null) + ); + + View content = activity.findViewById(R.id.screen_content); + assertFalse(viewTreeContainsText(content, "主 Agent 思考中")); + assertFalse(viewTreeContainsText(content, "主 Agent 回复超时")); + assertFalse(ReflectionHelpers.getField(activity, "masterAgentReplyWaiting")); + assertFalse(ReflectionHelpers.getField(activity, "masterAgentReplyTimedOut")); + assertEquals(null, ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId")); + } + @Test public void outgoingAttachmentMetaPrefersTimeOnly() throws Exception { Intent intent = new Intent() @@ -521,10 +647,21 @@ public class ProjectDetailActivityUiTest { } public static class TestProjectDetailActivity extends ProjectDetailActivity { + int replyWaitPollCount; + String lastReplyWaitBaselineMessageId; + boolean lastReplyWaitIncludeDispatchPlans; + @Override boolean shouldLoadOnCreate() { return false; } + + @Override + protected void enqueueReplyWaitPoll(String baselineMessageId, boolean includeDispatchPlans) { + replyWaitPollCount += 1; + lastReplyWaitBaselineMessageId = baselineMessageId; + lastReplyWaitIncludeDispatchPlans = includeDispatchPlans; + } } private static final class InMemorySharedPreferences implements SharedPreferences { diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index e6b6103..34c0c2e 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -134,6 +134,7 @@ cd /Users/kris/code/boss - 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本` - 主 Agent 单聊当前已改成“快速入队 + 异步回流”:`POST /api/v1/projects/master-agent/messages` 会先返回 `masterReplyState + task`,真实回复随后再回写消息账本 - 当前对话级 `agentControls` 已经生效:`master-agent` 会话支持 `modelOverride / reasoningEffortOverride`,并会优先作用到实际 OpenAI 回复和 Master Codex Node 执行 prompt +- 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示 - `GET /api/v1/app-logs` 当前已支持登录态分页查询 - `POST /api/v1/app-logs`、`POST /api/v1/devices/[deviceId]/skills`、`POST /api/v1/workers/[workerId]/thread-context` 当前都要求有效设备 token 或匹配登录会话 - 设备页当前只保留生产设备;旧演示脏数据已经从设备、运维和审计聚合视图里剔除